Compare commits
6 commits
2185dc49cd
...
22c8439779
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c8439779 | ||
|
|
d13f57de69 | ||
|
|
3399994977 | ||
|
|
026dd00ed4 | ||
|
|
e341fbcdcf | ||
|
|
6e283c1284 |
11 changed files with 610 additions and 0 deletions
73
pkg/coredeno/coredeno.go
Normal file
73
pkg/coredeno/coredeno.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures the CoreDeno sidecar.
|
||||||
|
type Options struct {
|
||||||
|
DenoPath string // path to deno binary (default: "deno")
|
||||||
|
SocketPath string // Unix socket path for gRPC
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions declares per-module Deno permission flags.
|
||||||
|
type Permissions struct {
|
||||||
|
Read []string
|
||||||
|
Write []string
|
||||||
|
Net []string
|
||||||
|
Run []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags converts permissions to Deno --allow-* CLI flags.
|
||||||
|
func (p Permissions) Flags() []string {
|
||||||
|
var flags []string
|
||||||
|
if len(p.Read) > 0 {
|
||||||
|
flags = append(flags, fmt.Sprintf("--allow-read=%s", strings.Join(p.Read, ",")))
|
||||||
|
}
|
||||||
|
if len(p.Write) > 0 {
|
||||||
|
flags = append(flags, fmt.Sprintf("--allow-write=%s", strings.Join(p.Write, ",")))
|
||||||
|
}
|
||||||
|
if len(p.Net) > 0 {
|
||||||
|
flags = append(flags, fmt.Sprintf("--allow-net=%s", strings.Join(p.Net, ",")))
|
||||||
|
}
|
||||||
|
if len(p.Run) > 0 {
|
||||||
|
flags = append(flags, fmt.Sprintf("--allow-run=%s", strings.Join(p.Run, ",")))
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSocketPath returns the default Unix socket path.
|
||||||
|
func DefaultSocketPath() string {
|
||||||
|
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
if xdg == "" {
|
||||||
|
xdg = "/tmp"
|
||||||
|
}
|
||||||
|
return filepath.Join(xdg, "core", "deno.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidecar manages a Deno child process.
|
||||||
|
type Sidecar struct {
|
||||||
|
opts Options
|
||||||
|
mu sync.RWMutex
|
||||||
|
cmd *exec.Cmd
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSidecar creates a Sidecar with the given options.
|
||||||
|
func NewSidecar(opts Options) *Sidecar {
|
||||||
|
if opts.DenoPath == "" {
|
||||||
|
opts.DenoPath = "deno"
|
||||||
|
}
|
||||||
|
if opts.SocketPath == "" {
|
||||||
|
opts.SocketPath = DefaultSocketPath()
|
||||||
|
}
|
||||||
|
return &Sidecar{opts: opts}
|
||||||
|
}
|
||||||
54
pkg/coredeno/coredeno_test.go
Normal file
54
pkg/coredeno/coredeno_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSidecar_Good(t *testing.T) {
|
||||||
|
opts := Options{
|
||||||
|
DenoPath: "echo",
|
||||||
|
SocketPath: "/tmp/test-core-deno.sock",
|
||||||
|
}
|
||||||
|
sc := NewSidecar(opts)
|
||||||
|
require.NotNil(t, sc)
|
||||||
|
assert.Equal(t, "echo", sc.opts.DenoPath)
|
||||||
|
assert.Equal(t, "/tmp/test-core-deno.sock", sc.opts.SocketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultSocketPath_Good(t *testing.T) {
|
||||||
|
path := DefaultSocketPath()
|
||||||
|
assert.Contains(t, path, "core/deno.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidecar_PermissionFlags_Good(t *testing.T) {
|
||||||
|
perms := Permissions{
|
||||||
|
Read: []string{"./data/"},
|
||||||
|
Write: []string{"./data/config.json"},
|
||||||
|
Net: []string{"pool.lthn.io:3333"},
|
||||||
|
Run: []string{"xmrig"},
|
||||||
|
}
|
||||||
|
flags := perms.Flags()
|
||||||
|
assert.Contains(t, flags, "--allow-read=./data/")
|
||||||
|
assert.Contains(t, flags, "--allow-write=./data/config.json")
|
||||||
|
assert.Contains(t, flags, "--allow-net=pool.lthn.io:3333")
|
||||||
|
assert.Contains(t, flags, "--allow-run=xmrig")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidecar_PermissionFlags_Empty(t *testing.T) {
|
||||||
|
perms := Permissions{}
|
||||||
|
flags := perms.Flags()
|
||||||
|
assert.Empty(t, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultSocketPath_XDG(t *testing.T) {
|
||||||
|
orig := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
defer os.Setenv("XDG_RUNTIME_DIR", orig)
|
||||||
|
|
||||||
|
os.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
|
||||||
|
path := DefaultSocketPath()
|
||||||
|
assert.Equal(t, "/run/user/1000/core/deno.sock", path)
|
||||||
|
}
|
||||||
69
pkg/coredeno/lifecycle.go
Normal file
69
pkg/coredeno/lifecycle.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start launches the Deno sidecar process with the given entrypoint args.
|
||||||
|
func (s *Sidecar) Start(ctx context.Context, args ...string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.cmd != nil {
|
||||||
|
return fmt.Errorf("coredeno: already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure socket directory exists
|
||||||
|
sockDir := filepath.Dir(s.opts.SocketPath)
|
||||||
|
if err := os.MkdirAll(sockDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("coredeno: mkdir %s: %w", sockDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale socket
|
||||||
|
os.Remove(s.opts.SocketPath)
|
||||||
|
|
||||||
|
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||||
|
s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...)
|
||||||
|
s.done = make(chan struct{})
|
||||||
|
if err := s.cmd.Start(); err != nil {
|
||||||
|
s.cmd = nil
|
||||||
|
s.cancel()
|
||||||
|
return fmt.Errorf("coredeno: start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor in background — waits for exit, then signals done
|
||||||
|
go func() {
|
||||||
|
s.cmd.Wait()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cmd = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
close(s.done)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels the context and waits for the process to exit.
|
||||||
|
func (s *Sidecar) Stop() error {
|
||||||
|
s.mu.RLock()
|
||||||
|
if s.cmd == nil {
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
done := s.done
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
s.cancel()
|
||||||
|
<-done
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns true if the sidecar process is alive.
|
||||||
|
func (s *Sidecar) IsRunning() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.cmd != nil
|
||||||
|
}
|
||||||
56
pkg/coredeno/lifecycle_test.go
Normal file
56
pkg/coredeno/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStart_Good(t *testing.T) {
|
||||||
|
sockDir := t.TempDir()
|
||||||
|
sc := NewSidecar(Options{
|
||||||
|
DenoPath: "sleep",
|
||||||
|
SocketPath: filepath.Join(sockDir, "test.sock"),
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := sc.Start(ctx, "10") // sleep 10 — will be killed by Stop
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, sc.IsRunning())
|
||||||
|
|
||||||
|
err = sc.Stop()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, sc.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStop_Good_NotStarted(t *testing.T) {
|
||||||
|
sc := NewSidecar(Options{DenoPath: "sleep"})
|
||||||
|
err := sc.Stop()
|
||||||
|
assert.NoError(t, err, "stopping a not-started sidecar should be a no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketDirCreated_Good(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sockPath := filepath.Join(dir, "sub", "deno.sock")
|
||||||
|
sc := NewSidecar(Options{
|
||||||
|
DenoPath: "sleep",
|
||||||
|
SocketPath: sockPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := sc.Start(ctx, "10")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer sc.Stop()
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "sub"))
|
||||||
|
assert.NoError(t, err, "socket directory should be created")
|
||||||
|
}
|
||||||
42
pkg/coredeno/permissions.go
Normal file
42
pkg/coredeno/permissions.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckPath returns true if the given path is under any of the allowed prefixes.
|
||||||
|
// Empty allowed list means deny all (secure by default).
|
||||||
|
func CheckPath(path string, allowed []string) bool {
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
for _, prefix := range allowed {
|
||||||
|
cleanPrefix := filepath.Clean(prefix)
|
||||||
|
if strings.HasPrefix(clean, cleanPrefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNet returns true if the given host:port is in the allowed list.
|
||||||
|
func CheckNet(addr string, allowed []string) bool {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == addr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckRun returns true if the given command is in the allowed list.
|
||||||
|
func CheckRun(cmd string, allowed []string) bool {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == cmd {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
40
pkg/coredeno/permissions_test.go
Normal file
40
pkg/coredeno/permissions_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckPath_Good_Allowed(t *testing.T) {
|
||||||
|
allowed := []string{"./data/", "./config/"}
|
||||||
|
assert.True(t, CheckPath("./data/file.txt", allowed))
|
||||||
|
assert.True(t, CheckPath("./config/app.json", allowed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPath_Bad_Denied(t *testing.T) {
|
||||||
|
allowed := []string{"./data/"}
|
||||||
|
assert.False(t, CheckPath("./secrets/key.pem", allowed))
|
||||||
|
assert.False(t, CheckPath("../escape/file", allowed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPath_Good_EmptyDenyAll(t *testing.T) {
|
||||||
|
assert.False(t, CheckPath("./anything", nil))
|
||||||
|
assert.False(t, CheckPath("./anything", []string{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckNet_Good_Allowed(t *testing.T) {
|
||||||
|
allowed := []string{"pool.lthn.io:3333", "api.lthn.io:443"}
|
||||||
|
assert.True(t, CheckNet("pool.lthn.io:3333", allowed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckNet_Bad_Denied(t *testing.T) {
|
||||||
|
allowed := []string{"pool.lthn.io:3333"}
|
||||||
|
assert.False(t, CheckNet("evil.com:80", allowed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRun_Good(t *testing.T) {
|
||||||
|
allowed := []string{"xmrig", "sha256sum"}
|
||||||
|
assert.True(t, CheckRun("xmrig", allowed))
|
||||||
|
assert.False(t, CheckRun("rm", allowed))
|
||||||
|
}
|
||||||
81
pkg/coredeno/proto/coredeno.proto
Normal file
81
pkg/coredeno/proto/coredeno.proto
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
package coredeno;
|
||||||
|
option go_package = "forge.lthn.ai/core/go/pkg/coredeno/proto";
|
||||||
|
|
||||||
|
// CoreService is implemented by CoreGO — Deno calls this for I/O.
|
||||||
|
service CoreService {
|
||||||
|
// Filesystem (gated by manifest permissions)
|
||||||
|
rpc FileRead(FileReadRequest) returns (FileReadResponse);
|
||||||
|
rpc FileWrite(FileWriteRequest) returns (FileWriteResponse);
|
||||||
|
rpc FileList(FileListRequest) returns (FileListResponse);
|
||||||
|
rpc FileDelete(FileDeleteRequest) returns (FileDeleteResponse);
|
||||||
|
|
||||||
|
// Object store
|
||||||
|
rpc StoreGet(StoreGetRequest) returns (StoreGetResponse);
|
||||||
|
rpc StoreSet(StoreSetRequest) returns (StoreSetResponse);
|
||||||
|
|
||||||
|
// Process management
|
||||||
|
rpc ProcessStart(ProcessStartRequest) returns (ProcessStartResponse);
|
||||||
|
rpc ProcessStop(ProcessStopRequest) returns (ProcessStopResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DenoService is implemented by CoreDeno — Go calls this for module lifecycle.
|
||||||
|
service DenoService {
|
||||||
|
rpc LoadModule(LoadModuleRequest) returns (LoadModuleResponse);
|
||||||
|
rpc UnloadModule(UnloadModuleRequest) returns (UnloadModuleResponse);
|
||||||
|
rpc ModuleStatus(ModuleStatusRequest) returns (ModuleStatusResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core (Go-side) messages ---
|
||||||
|
|
||||||
|
message FileReadRequest { string path = 1; string module_code = 2; }
|
||||||
|
message FileReadResponse { string content = 1; }
|
||||||
|
|
||||||
|
message FileWriteRequest { string path = 1; string content = 2; string module_code = 3; }
|
||||||
|
message FileWriteResponse { bool ok = 1; }
|
||||||
|
|
||||||
|
message FileListRequest { string path = 1; string module_code = 2; }
|
||||||
|
message FileListResponse {
|
||||||
|
repeated FileEntry entries = 1;
|
||||||
|
}
|
||||||
|
message FileEntry {
|
||||||
|
string name = 1;
|
||||||
|
bool is_dir = 2;
|
||||||
|
int64 size = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FileDeleteRequest { string path = 1; string module_code = 2; }
|
||||||
|
message FileDeleteResponse { bool ok = 1; }
|
||||||
|
|
||||||
|
message StoreGetRequest { string group = 1; string key = 2; }
|
||||||
|
message StoreGetResponse { string value = 1; bool found = 2; }
|
||||||
|
|
||||||
|
message StoreSetRequest { string group = 1; string key = 2; string value = 3; }
|
||||||
|
message StoreSetResponse { bool ok = 1; }
|
||||||
|
|
||||||
|
message ProcessStartRequest { string command = 1; repeated string args = 2; string module_code = 3; }
|
||||||
|
message ProcessStartResponse { string process_id = 1; }
|
||||||
|
|
||||||
|
message ProcessStopRequest { string process_id = 1; }
|
||||||
|
message ProcessStopResponse { bool ok = 1; }
|
||||||
|
|
||||||
|
// --- Deno-side messages ---
|
||||||
|
|
||||||
|
message LoadModuleRequest { string code = 1; string entry_point = 2; repeated string permissions = 3; }
|
||||||
|
message LoadModuleResponse { bool ok = 1; string error = 2; }
|
||||||
|
|
||||||
|
message UnloadModuleRequest { string code = 1; }
|
||||||
|
message UnloadModuleResponse { bool ok = 1; }
|
||||||
|
|
||||||
|
message ModuleStatusRequest { string code = 1; }
|
||||||
|
message ModuleStatusResponse {
|
||||||
|
string code = 1;
|
||||||
|
enum Status {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
LOADING = 1;
|
||||||
|
RUNNING = 2;
|
||||||
|
STOPPED = 3;
|
||||||
|
ERRORED = 4;
|
||||||
|
}
|
||||||
|
Status status = 2;
|
||||||
|
}
|
||||||
33
pkg/coredeno/service.go
Normal file
33
pkg/coredeno/service.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Service wraps the CoreDeno sidecar for framework lifecycle integration.
|
||||||
|
// Implements Startable (OnStartup) and Stoppable (OnShutdown) interfaces.
|
||||||
|
type Service struct {
|
||||||
|
sidecar *Sidecar
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a CoreDeno service ready for framework registration.
|
||||||
|
func NewService(opts Options) *Service {
|
||||||
|
return &Service{
|
||||||
|
sidecar: NewSidecar(opts),
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStartup starts the Deno sidecar. Called by the framework.
|
||||||
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnShutdown stops the Deno sidecar. Called by the framework.
|
||||||
|
func (s *Service) OnShutdown() error {
|
||||||
|
return s.sidecar.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidecar returns the underlying sidecar for direct access.
|
||||||
|
func (s *Service) Sidecar() *Sidecar {
|
||||||
|
return s.sidecar
|
||||||
|
}
|
||||||
30
pkg/coredeno/service_test.go
Normal file
30
pkg/coredeno/service_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package coredeno
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewService_Good(t *testing.T) {
|
||||||
|
opts := Options{
|
||||||
|
DenoPath: "echo",
|
||||||
|
SocketPath: "/tmp/test-service.sock",
|
||||||
|
}
|
||||||
|
svc := NewService(opts)
|
||||||
|
require.NotNil(t, svc)
|
||||||
|
assert.NotNil(t, svc.sidecar)
|
||||||
|
assert.Equal(t, "echo", svc.sidecar.opts.DenoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_OnShutdown_Good_NotStarted(t *testing.T) {
|
||||||
|
svc := NewService(Options{DenoPath: "echo"})
|
||||||
|
err := svc.OnShutdown()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_Sidecar_Good(t *testing.T) {
|
||||||
|
svc := NewService(Options{DenoPath: "echo"})
|
||||||
|
assert.NotNil(t, svc.Sidecar())
|
||||||
|
}
|
||||||
67
pkg/marketplace/marketplace.go
Normal file
67
pkg/marketplace/marketplace.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package marketplace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module is a marketplace entry pointing to a module's Git repo.
|
||||||
|
type Module struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
SignKey string `json:"sign_key"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index is the root marketplace catalog.
|
||||||
|
type Index struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Modules []Module `json:"modules"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIndex decodes a marketplace index.json.
|
||||||
|
func ParseIndex(data []byte) (*Index, error) {
|
||||||
|
var idx Index
|
||||||
|
if err := json.Unmarshal(data, &idx); err != nil {
|
||||||
|
return nil, fmt.Errorf("marketplace.ParseIndex: %w", err)
|
||||||
|
}
|
||||||
|
return &idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns modules matching the query in code, name, or category.
|
||||||
|
func (idx *Index) Search(query string) []Module {
|
||||||
|
q := strings.ToLower(query)
|
||||||
|
var results []Module
|
||||||
|
for _, m := range idx.Modules {
|
||||||
|
if strings.Contains(strings.ToLower(m.Code), q) ||
|
||||||
|
strings.Contains(strings.ToLower(m.Name), q) ||
|
||||||
|
strings.Contains(strings.ToLower(m.Category), q) {
|
||||||
|
results = append(results, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByCategory returns all modules in the given category.
|
||||||
|
func (idx *Index) ByCategory(category string) []Module {
|
||||||
|
var results []Module
|
||||||
|
for _, m := range idx.Modules {
|
||||||
|
if m.Category == category {
|
||||||
|
results = append(results, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find returns the module with the given code, or false if not found.
|
||||||
|
func (idx *Index) Find(code string) (Module, bool) {
|
||||||
|
for _, m := range idx.Modules {
|
||||||
|
if m.Code == code {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Module{}, false
|
||||||
|
}
|
||||||
65
pkg/marketplace/marketplace_test.go
Normal file
65
pkg/marketplace/marketplace_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package marketplace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseIndex_Good(t *testing.T) {
|
||||||
|
raw := `{
|
||||||
|
"version": 1,
|
||||||
|
"modules": [
|
||||||
|
{"code": "mining-xmrig", "name": "XMRig Miner", "repo": "https://forge.lthn.io/host-uk/mod-xmrig.git", "sign_key": "abc123", "category": "miner"},
|
||||||
|
{"code": "utils-cyberchef", "name": "CyberChef", "repo": "https://forge.lthn.io/host-uk/mod-cyberchef.git", "sign_key": "def456", "category": "utils"}
|
||||||
|
],
|
||||||
|
"categories": ["miner", "utils"]
|
||||||
|
}`
|
||||||
|
idx, err := ParseIndex([]byte(raw))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, idx.Version)
|
||||||
|
assert.Len(t, idx.Modules, 2)
|
||||||
|
assert.Equal(t, "mining-xmrig", idx.Modules[0].Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_Good(t *testing.T) {
|
||||||
|
idx := &Index{
|
||||||
|
Modules: []Module{
|
||||||
|
{Code: "mining-xmrig", Name: "XMRig Miner", Category: "miner"},
|
||||||
|
{Code: "utils-cyberchef", Name: "CyberChef", Category: "utils"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
results := idx.Search("miner")
|
||||||
|
assert.Len(t, results, 1)
|
||||||
|
assert.Equal(t, "mining-xmrig", results[0].Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByCategory_Good(t *testing.T) {
|
||||||
|
idx := &Index{
|
||||||
|
Modules: []Module{
|
||||||
|
{Code: "a", Category: "miner"},
|
||||||
|
{Code: "b", Category: "utils"},
|
||||||
|
{Code: "c", Category: "miner"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
miners := idx.ByCategory("miner")
|
||||||
|
assert.Len(t, miners, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFind_Good(t *testing.T) {
|
||||||
|
idx := &Index{
|
||||||
|
Modules: []Module{
|
||||||
|
{Code: "mining-xmrig", Name: "XMRig"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m, ok := idx.Find("mining-xmrig")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "XMRig", m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFind_Bad_NotFound(t *testing.T) {
|
||||||
|
idx := &Index{}
|
||||||
|
_, ok := idx.Find("nope")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue