diff --git a/pkg/coredeno/integration_test.go b/pkg/coredeno/integration_test.go index da61c8c..8ee80fd 100644 --- a/pkg/coredeno/integration_test.go +++ b/pkg/coredeno/integration_test.go @@ -12,6 +12,7 @@ import ( pb "forge.lthn.ai/core/go/pkg/coredeno/proto" core "forge.lthn.ai/core/go/pkg/framework/core" + "forge.lthn.ai/core/go/pkg/marketplace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -339,3 +340,160 @@ permissions: assert.NoError(t, err) assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") } + +// createModuleRepo creates a git repo containing a test module with manifest + main.ts. +// The module's init() writes to the store to prove the I/O bridge works. +func createModuleRepo(t *testing.T, code string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), code+"-repo") + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "view.yml"), []byte(` +code: `+code+` +name: Test Module `+code+` +version: "1.0" +permissions: + read: ["./"] +`), 0644)) + + // Module that writes to store to prove it ran + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.ts"), []byte(` +export async function init(core: any) { + await core.storeSet("`+code+`", "installed", "yes"); +} +`), 0644)) + + gitCmd := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{ + "-C", dir, "-c", "user.email=test@test.com", "-c", "user.name=test", + }, args...)...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) + } + gitCmd("init") + gitCmd("add", ".") + gitCmd("commit", "-m", "init") + + return dir +} + +func TestIntegration_Tier4_MarketplaceInstall_Good(t *testing.T) { + denoPath := findDeno(t) + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + denoSockPath := filepath.Join(tmpDir, "deno.sock") + + // Write app manifest + coreDir := filepath.Join(tmpDir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(` +code: tier4-test +name: Tier 4 Test +version: "1.0" +permissions: + read: ["./"] +`), 0644)) + + entryPoint := runtimeEntryPoint(t) + + opts := Options{ + DenoPath: denoPath, + SocketPath: sockPath, + DenoSocketPath: denoSockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint}, + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, err := factory(c) + require.NoError(t, err) + svc := result.(*Service) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify sidecar and Deno client are up + require.Eventually(t, func() bool { + _, err := os.Stat(denoSockPath) + return err == nil + }, 10*time.Second, 50*time.Millisecond, "deno socket should appear") + + require.NotNil(t, svc.DenoClient(), "DenoClient should be connected") + require.NotNil(t, svc.Installer(), "Installer should be available") + + // Create a test module repo and install it + moduleRepo := createModuleRepo(t, "market-mod") + err = svc.Installer().Install(ctx, marketplace.Module{ + Code: "market-mod", + Repo: moduleRepo, + }) + require.NoError(t, err) + + // Verify the module was installed on disk + modulesDir := filepath.Join(tmpDir, "modules", "market-mod") + require.DirExists(t, modulesDir) + + // Verify Installed() returns it + installed, err := svc.Installer().Installed() + require.NoError(t, err) + require.Len(t, installed, 1) + assert.Equal(t, "market-mod", installed[0].Code) + assert.Equal(t, "1.0", installed[0].Version) + + // Load the installed module into the Deno runtime + mod := installed[0] + loadResp, err := svc.DenoClient().LoadModule(mod.Code, mod.EntryPoint, ModulePermissions{ + Read: mod.Permissions.Read, + }) + require.NoError(t, err) + assert.True(t, loadResp.Ok) + + // Wait for module to reach RUNNING + require.Eventually(t, func() bool { + resp, err := svc.DenoClient().ModuleStatus("market-mod") + return err == nil && resp.Status == "RUNNING" + }, 10*time.Second, 100*time.Millisecond, "installed module should be RUNNING") + + // Verify the module wrote to the store via I/O bridge + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + coreClient := pb.NewCoreServiceClient(conn) + require.Eventually(t, func() bool { + resp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "market-mod", Key: "installed", + }) + return err == nil && resp.Found && resp.Value == "yes" + }, 5*time.Second, 100*time.Millisecond, "installed module should have written to store via I/O bridge") + + // Unload and remove + unloadResp, err := svc.DenoClient().UnloadModule("market-mod") + require.NoError(t, err) + assert.True(t, unloadResp.Ok) + + err = svc.Installer().Remove("market-mod") + require.NoError(t, err) + assert.NoDirExists(t, modulesDir, "module directory should be removed") + + installed2, err := svc.Installer().Installed() + require.NoError(t, err) + assert.Empty(t, installed2, "no modules should be installed after remove") + + // Clean shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped") +} diff --git a/pkg/coredeno/service.go b/pkg/coredeno/service.go index fe6bd70..80e6f8e 100644 --- a/pkg/coredeno/service.go +++ b/pkg/coredeno/service.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "os" + "path/filepath" "time" core "forge.lthn.ai/core/go/pkg/framework/core" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/marketplace" "forge.lthn.ai/core/go/pkg/store" ) @@ -26,6 +28,7 @@ type Service struct { grpcCancel context.CancelFunc grpcDone chan error denoClient *DenoClient + installer *marketplace.Installer } // NewServiceFactory returns a factory function for framework registration via WithService. @@ -116,6 +119,27 @@ func (s *Service) OnStartup(ctx context.Context) error { } } + // 8. Create installer and auto-load installed modules + if opts.AppRoot != "" { + modulesDir := filepath.Join(opts.AppRoot, "modules") + s.installer = marketplace.NewInstaller(modulesDir, s.store) + + if s.denoClient != nil { + installed, listErr := s.installer.Installed() + if listErr == nil { + for _, mod := range installed { + perms := ModulePermissions{ + Read: mod.Permissions.Read, + Write: mod.Permissions.Write, + Net: mod.Permissions.Net, + Run: mod.Permissions.Run, + } + s.denoClient.LoadModule(mod.Code, mod.EntryPoint, perms) + } + } + } + } + return nil } @@ -159,6 +183,12 @@ func (s *Service) DenoClient() *DenoClient { return s.denoClient } +// Installer returns the marketplace module installer. +// Returns nil if AppRoot was not set. +func (s *Service) Installer() *marketplace.Installer { + return s.installer +} + // waitForSocket polls until a Unix socket file appears or the context/timeout expires. func waitForSocket(ctx context.Context, path string, timeout time.Duration) error { deadline := time.Now().Add(timeout) diff --git a/pkg/manifest/loader.go b/pkg/manifest/loader.go index ea3e8a4..1136590 100644 --- a/pkg/manifest/loader.go +++ b/pkg/manifest/loader.go @@ -11,8 +11,8 @@ import ( const manifestPath = ".core/view.yml" -// marshalYAML serializes a manifest to YAML bytes. -func marshalYAML(m *Manifest) ([]byte, error) { +// MarshalYAML serializes a manifest to YAML bytes. +func MarshalYAML(m *Manifest) ([]byte, error) { return yaml.Marshal(m) } diff --git a/pkg/manifest/loader_test.go b/pkg/manifest/loader_test.go index f68c118..95f857f 100644 --- a/pkg/manifest/loader_test.go +++ b/pkg/manifest/loader_test.go @@ -39,7 +39,7 @@ func TestLoadVerified_Good(t *testing.T) { } _ = Sign(m, priv) - raw, _ := marshalYAML(m) + raw, _ := MarshalYAML(m) fs := io.NewMockMedium() fs.Files[".core/view.yml"] = string(raw) @@ -53,7 +53,7 @@ func TestLoadVerified_Bad_Tampered(t *testing.T) { m := &Manifest{Code: "app", Version: "1.0.0"} _ = Sign(m, priv) - raw, _ := marshalYAML(m) + raw, _ := MarshalYAML(m) tampered := "code: evil\n" + string(raw)[6:] fs := io.NewMockMedium() fs.Files[".core/view.yml"] = tampered diff --git a/pkg/marketplace/installer.go b/pkg/marketplace/installer.go new file mode 100644 index 0000000..ac9a690 --- /dev/null +++ b/pkg/marketplace/installer.go @@ -0,0 +1,194 @@ +package marketplace + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/store" +) + +const storeGroup = "_modules" + +// Installer handles module installation from Git repos. +type Installer struct { + modulesDir string + store *store.Store +} + +// NewInstaller creates a new module installer. +func NewInstaller(modulesDir string, st *store.Store) *Installer { + return &Installer{ + modulesDir: modulesDir, + store: st, + } +} + +// InstalledModule holds stored metadata about an installed module. +type InstalledModule struct { + Code string `json:"code"` + Name string `json:"name"` + Version string `json:"version"` + Repo string `json:"repo"` + EntryPoint string `json:"entry_point"` + Permissions manifest.Permissions `json:"permissions"` + InstalledAt string `json:"installed_at"` +} + +// Install clones a module repo, verifies its manifest signature, and registers it. +func (i *Installer) Install(ctx context.Context, mod Module) error { + // Check if already installed + if _, err := i.store.Get(storeGroup, mod.Code); err == nil { + return fmt.Errorf("marketplace: module %q already installed", mod.Code) + } + + dest := filepath.Join(i.modulesDir, mod.Code) + if err := os.MkdirAll(i.modulesDir, 0755); err != nil { + return fmt.Errorf("marketplace: mkdir: %w", err) + } + if err := gitClone(ctx, mod.Repo, dest); err != nil { + return fmt.Errorf("marketplace: clone %s: %w", mod.Repo, err) + } + + // On any error after clone, clean up the directory + cleanup := true + defer func() { + if cleanup { + os.RemoveAll(dest) + } + }() + + medium, err := io.NewSandboxed(dest) + if err != nil { + return fmt.Errorf("marketplace: medium: %w", err) + } + + m, err := loadManifest(medium, mod.SignKey) + if err != nil { + return err + } + + entryPoint := filepath.Join(dest, "main.ts") + installed := InstalledModule{ + Code: mod.Code, + Name: m.Name, + Version: m.Version, + Repo: mod.Repo, + EntryPoint: entryPoint, + Permissions: m.Permissions, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + + data, err := json.Marshal(installed) + if err != nil { + return fmt.Errorf("marketplace: marshal: %w", err) + } + + if err := i.store.Set(storeGroup, mod.Code, string(data)); err != nil { + return fmt.Errorf("marketplace: store: %w", err) + } + + cleanup = false + return nil +} + +// Remove uninstalls a module by deleting its files and store entry. +func (i *Installer) Remove(code string) error { + if _, err := i.store.Get(storeGroup, code); err != nil { + return fmt.Errorf("marketplace: module %q not installed", code) + } + + dest := filepath.Join(i.modulesDir, code) + os.RemoveAll(dest) + + return i.store.Delete(storeGroup, code) +} + +// Update pulls latest changes and re-verifies the manifest. +func (i *Installer) Update(ctx context.Context, code string) error { + raw, err := i.store.Get(storeGroup, code) + if err != nil { + return fmt.Errorf("marketplace: module %q not installed", code) + } + + var installed InstalledModule + if err := json.Unmarshal([]byte(raw), &installed); err != nil { + return fmt.Errorf("marketplace: unmarshal: %w", err) + } + + dest := filepath.Join(i.modulesDir, code) + + cmd := exec.CommandContext(ctx, "git", "-C", dest, "pull", "--ff-only") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("marketplace: pull: %s: %w", strings.TrimSpace(string(output)), err) + } + + // Reload manifest + medium, mErr := io.NewSandboxed(dest) + if mErr != nil { + return fmt.Errorf("marketplace: medium: %w", mErr) + } + m, mErr := manifest.Load(medium, ".") + if mErr != nil { + return fmt.Errorf("marketplace: reload manifest: %w", mErr) + } + + // Update stored metadata + installed.Name = m.Name + installed.Version = m.Version + installed.Permissions = m.Permissions + + data, err := json.Marshal(installed) + if err != nil { + return fmt.Errorf("marketplace: marshal: %w", err) + } + + return i.store.Set(storeGroup, code, string(data)) +} + +// Installed returns all installed module metadata. +func (i *Installer) Installed() ([]InstalledModule, error) { + all, err := i.store.GetAll(storeGroup) + if err != nil { + return nil, fmt.Errorf("marketplace: list: %w", err) + } + + var modules []InstalledModule + for _, raw := range all { + var m InstalledModule + if err := json.Unmarshal([]byte(raw), &m); err != nil { + continue + } + modules = append(modules, m) + } + return modules, nil +} + +// loadManifest loads and optionally verifies a module manifest. +func loadManifest(medium io.Medium, signKey string) (*manifest.Manifest, error) { + if signKey != "" { + pubBytes, err := hex.DecodeString(signKey) + if err != nil { + return nil, fmt.Errorf("marketplace: decode sign key: %w", err) + } + return manifest.LoadVerified(medium, ".", pubBytes) + } + return manifest.Load(medium, ".") +} + +// gitClone clones a repository with --depth=1. +func gitClone(ctx context.Context, repo, dest string) error { + cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", repo, dest) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} diff --git a/pkg/marketplace/installer_test.go b/pkg/marketplace/installer_test.go new file mode 100644 index 0000000..a8164fe --- /dev/null +++ b/pkg/marketplace/installer_test.go @@ -0,0 +1,263 @@ +package marketplace + +import ( + "context" + "crypto/ed25519" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "testing" + + "forge.lthn.ai/core/go/pkg/manifest" + "forge.lthn.ai/core/go/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestRepo creates a bare-bones git repo with a manifest and main.ts. +// Returns the repo path (usable as Module.Repo for local clone). +func createTestRepo(t *testing.T, code, version string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), code) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) + + manifestYAML := "code: " + code + "\nname: Test " + code + "\nversion: \"" + version + "\"\n" + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".core", "view.yml"), + []byte(manifestYAML), 0644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "main.ts"), + []byte("export async function init(core: any) {}\n"), 0644, + )) + + runGit(t, dir, "init") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + return dir +} + +// createSignedTestRepo creates a git repo with a signed manifest. +// Returns (repo path, hex-encoded public key). +func createSignedTestRepo(t *testing.T, code, version string) (string, string) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + dir := filepath.Join(t.TempDir(), code) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) + + m := &manifest.Manifest{ + Code: code, + Name: "Test " + code, + Version: version, + } + require.NoError(t, manifest.Sign(m, priv)) + + data, err := manifest.MarshalYAML(m) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "view.yml"), data, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.ts"), []byte("export async function init(core: any) {}\n"), 0644)) + + runGit(t, dir, "init") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + return dir, hex.EncodeToString(pub) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.email=test@test.com", "-c", "user.name=test"}, args...)...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +func TestInstall_Good(t *testing.T) { + repo := createTestRepo(t, "hello-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + err = inst.Install(context.Background(), Module{ + Code: "hello-mod", + Repo: repo, + }) + require.NoError(t, err) + + // Verify directory exists + _, err = os.Stat(filepath.Join(modulesDir, "hello-mod", "main.ts")) + assert.NoError(t, err, "main.ts should exist in installed module") + + // Verify store entry + raw, err := st.Get("_modules", "hello-mod") + require.NoError(t, err) + assert.Contains(t, raw, `"code":"hello-mod"`) + assert.Contains(t, raw, `"version":"1.0"`) +} + +func TestInstall_Good_Signed(t *testing.T) { + repo, signKey := createSignedTestRepo(t, "signed-mod", "2.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + err = inst.Install(context.Background(), Module{ + Code: "signed-mod", + Repo: repo, + SignKey: signKey, + }) + require.NoError(t, err) + + raw, err := st.Get("_modules", "signed-mod") + require.NoError(t, err) + assert.Contains(t, raw, `"version":"2.0"`) +} + +func TestInstall_Bad_AlreadyInstalled(t *testing.T) { + repo := createTestRepo(t, "dup-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + mod := Module{Code: "dup-mod", Repo: repo} + + require.NoError(t, inst.Install(context.Background(), mod)) + err = inst.Install(context.Background(), mod) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already installed") +} + +func TestInstall_Bad_InvalidSignature(t *testing.T) { + // Sign with key A, verify with key B + repo, _ := createSignedTestRepo(t, "bad-sig", "1.0") + _, wrongKey := createSignedTestRepo(t, "dummy", "1.0") // different key + + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + err = inst.Install(context.Background(), Module{ + Code: "bad-sig", + Repo: repo, + SignKey: wrongKey, + }) + assert.Error(t, err) + + // Verify directory was cleaned up + _, statErr := os.Stat(filepath.Join(modulesDir, "bad-sig")) + assert.True(t, os.IsNotExist(statErr), "directory should be cleaned up on failure") +} + +func TestRemove_Good(t *testing.T) { + repo := createTestRepo(t, "rm-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + require.NoError(t, inst.Install(context.Background(), Module{Code: "rm-mod", Repo: repo})) + + err = inst.Remove("rm-mod") + require.NoError(t, err) + + // Directory gone + _, statErr := os.Stat(filepath.Join(modulesDir, "rm-mod")) + assert.True(t, os.IsNotExist(statErr)) + + // Store entry gone + _, err = st.Get("_modules", "rm-mod") + assert.Error(t, err) +} + +func TestRemove_Bad_NotInstalled(t *testing.T) { + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(t.TempDir(), st) + err = inst.Remove("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not installed") +} + +func TestInstalled_Good(t *testing.T) { + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + + repo1 := createTestRepo(t, "mod-a", "1.0") + repo2 := createTestRepo(t, "mod-b", "2.0") + + require.NoError(t, inst.Install(context.Background(), Module{Code: "mod-a", Repo: repo1})) + require.NoError(t, inst.Install(context.Background(), Module{Code: "mod-b", Repo: repo2})) + + installed, err := inst.Installed() + require.NoError(t, err) + assert.Len(t, installed, 2) + + codes := map[string]bool{} + for _, m := range installed { + codes[m.Code] = true + } + assert.True(t, codes["mod-a"]) + assert.True(t, codes["mod-b"]) +} + +func TestInstalled_Good_Empty(t *testing.T) { + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(t.TempDir(), st) + installed, err := inst.Installed() + require.NoError(t, err) + assert.Empty(t, installed) +} + +func TestUpdate_Good(t *testing.T) { + repo := createTestRepo(t, "upd-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(modulesDir, st) + require.NoError(t, inst.Install(context.Background(), Module{Code: "upd-mod", Repo: repo})) + + // Update the origin repo + newManifest := "code: upd-mod\nname: Updated Module\nversion: \"2.0\"\n" + require.NoError(t, os.WriteFile(filepath.Join(repo, ".core", "view.yml"), []byte(newManifest), 0644)) + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "bump version") + + err = inst.Update(context.Background(), "upd-mod") + require.NoError(t, err) + + // Verify updated metadata + installed, err := inst.Installed() + require.NoError(t, err) + require.Len(t, installed, 1) + assert.Equal(t, "2.0", installed[0].Version) + assert.Equal(t, "Updated Module", installed[0].Name) +} diff --git a/pkg/store/store.go b/pkg/store/store.go index eaa2774..6f717e5 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -95,6 +95,25 @@ func (s *Store) DeleteGroup(group string) error { return nil } +// GetAll returns all key-value pairs in a group. +func (s *Store) GetAll(group string) (map[string]string, error) { + rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group) + if err != nil { + return nil, fmt.Errorf("store.GetAll: %w", err) + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("store.GetAll: scan: %w", err) + } + result[k] = v + } + return result, nil +} + // Render loads all key-value pairs from a group and renders a Go template. func (s *Store) Render(tmplStr, group string) (string, error) { rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group) diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go index 1782ed2..b62b88b 100644 --- a/pkg/store/store_test.go +++ b/pkg/store/store_test.go @@ -66,6 +66,28 @@ func TestDeleteGroup_Good(t *testing.T) { assert.Equal(t, 0, n) } +func TestGetAll_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + _ = s.Set("other", "c", "3") + + all, err := s.GetAll("grp") + require.NoError(t, err) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, all) +} + +func TestGetAll_Good_Empty(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + all, err := s.GetAll("empty") + require.NoError(t, err) + assert.Empty(t, all) +} + func TestRender_Good(t *testing.T) { s, _ := New(":memory:") defer s.Close()