diff --git a/go.mod b/go.mod index d8169da..655e860 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,8 @@ require ( golang.org/x/net v0.50.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 + google.golang.org/grpc v1.79.1 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.45.0 ) @@ -113,8 +115,6 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index d043f23..42687bd 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -245,21 +247,16 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -302,12 +299,8 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/pkg/coredeno/coredeno.go b/pkg/coredeno/coredeno.go index a45bbe5..ee50ddb 100644 --- a/pkg/coredeno/coredeno.go +++ b/pkg/coredeno/coredeno.go @@ -2,6 +2,7 @@ package coredeno import ( "context" + "crypto/ed25519" "fmt" "os" "os/exec" @@ -12,8 +13,12 @@ import ( // Options configures the CoreDeno sidecar. type Options struct { - DenoPath string // path to deno binary (default: "deno") - SocketPath string // Unix socket path for gRPC + DenoPath string // path to deno binary (default: "deno") + SocketPath string // Unix socket path for gRPC + AppRoot string // app root directory (sandboxed I/O) + StoreDBPath string // SQLite DB path (default: AppRoot/.core/store.db) + PublicKey ed25519.PublicKey // ed25519 public key for manifest verification (optional) + SidecarArgs []string // args passed to the sidecar process } // Permissions declares per-module Deno permission flags. @@ -69,5 +74,8 @@ func NewSidecar(opts Options) *Sidecar { if opts.SocketPath == "" { opts.SocketPath = DefaultSocketPath() } + if opts.StoreDBPath == "" && opts.AppRoot != "" { + opts.StoreDBPath = filepath.Join(opts.AppRoot, ".core", "store.db") + } return &Sidecar{opts: opts} } diff --git a/pkg/coredeno/coredeno_test.go b/pkg/coredeno/coredeno_test.go index dec79bf..1da31c8 100644 --- a/pkg/coredeno/coredeno_test.go +++ b/pkg/coredeno/coredeno_test.go @@ -44,6 +44,34 @@ func TestSidecar_PermissionFlags_Empty(t *testing.T) { assert.Empty(t, flags) } +func TestOptions_AppRoot_Good(t *testing.T) { + opts := Options{ + DenoPath: "deno", + SocketPath: "/tmp/test.sock", + AppRoot: "/app", + StoreDBPath: "/app/.core/store.db", + } + sc := NewSidecar(opts) + assert.Equal(t, "/app", sc.opts.AppRoot) + assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath) +} + +func TestOptions_StoreDBPath_Default_Good(t *testing.T) { + opts := Options{AppRoot: "/app"} + sc := NewSidecar(opts) + assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath, + "StoreDBPath should default to AppRoot/.core/store.db") +} + +func TestOptions_SidecarArgs_Good(t *testing.T) { + opts := Options{ + DenoPath: "deno", + SidecarArgs: []string{"run", "--allow-env", "main.ts"}, + } + sc := NewSidecar(opts) + assert.Equal(t, []string{"run", "--allow-env", "main.ts"}, sc.opts.SidecarArgs) +} + func TestDefaultSocketPath_XDG(t *testing.T) { orig := os.Getenv("XDG_RUNTIME_DIR") defer os.Setenv("XDG_RUNTIME_DIR", orig) diff --git a/pkg/coredeno/integration_test.go b/pkg/coredeno/integration_test.go new file mode 100644 index 0000000..ce83fe0 --- /dev/null +++ b/pkg/coredeno/integration_test.go @@ -0,0 +1,110 @@ +//go:build integration + +package coredeno + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + core "forge.lthn.ai/core/go/pkg/framework/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestIntegration_FullBoot_Good(t *testing.T) { + denoPath, err := exec.LookPath("deno") + if err != nil { + // Check ~/.deno/bin/deno + home, _ := os.UserHomeDir() + denoPath = filepath.Join(home, ".deno", "bin", "deno") + if _, err := os.Stat(denoPath); err != nil { + t.Skip("deno not installed") + } + } + + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + + // Write a 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: integration-test +name: Integration Test +version: "1.0" +permissions: + read: ["./data/"] +`), 0644)) + + // Copy the runtime entry point + runtimeDir := filepath.Join(coreDir, "runtime") + require.NoError(t, os.MkdirAll(runtimeDir, 0755)) + src, err := os.ReadFile("runtime/main.ts") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(runtimeDir, "main.ts"), src, 0644)) + + entryPoint := filepath.Join(runtimeDir, "main.ts") + + opts := Options{ + DenoPath: denoPath, + SocketPath: sockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"run", "--allow-env", 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(), 10*time.Second) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify gRPC is working + require.Eventually(t, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }, 5*time.Second, 50*time.Millisecond, "socket should appear") + + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + client := pb.NewCoreServiceClient(conn) + _, err = client.StoreSet(ctx, &pb.StoreSetRequest{ + Group: "integration", Key: "boot", Value: "ok", + }) + require.NoError(t, err) + + resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "integration", Key: "boot", + }) + require.NoError(t, err) + assert.Equal(t, "ok", resp.Value) + assert.True(t, resp.Found) + + // Verify sidecar is running + assert.True(t, svc.sidecar.IsRunning(), "Deno sidecar should be running") + + // 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/lifecycle.go b/pkg/coredeno/lifecycle.go index 61d5a7c..f5975b5 100644 --- a/pkg/coredeno/lifecycle.go +++ b/pkg/coredeno/lifecycle.go @@ -28,6 +28,7 @@ func (s *Sidecar) Start(ctx context.Context, args ...string) error { s.ctx, s.cancel = context.WithCancel(ctx) s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...) + s.cmd.Env = append(os.Environ(), "CORE_SOCKET="+s.opts.SocketPath) s.done = make(chan struct{}) if err := s.cmd.Start(); err != nil { s.cmd = nil diff --git a/pkg/coredeno/lifecycle_test.go b/pkg/coredeno/lifecycle_test.go index a8ff90f..ef14c21 100644 --- a/pkg/coredeno/lifecycle_test.go +++ b/pkg/coredeno/lifecycle_test.go @@ -36,6 +36,38 @@ func TestStop_Good_NotStarted(t *testing.T) { assert.NoError(t, err, "stopping a not-started sidecar should be a no-op") } +func TestStart_Good_EnvPassedToChild(t *testing.T) { + sockDir := t.TempDir() + sockPath := filepath.Join(sockDir, "test.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() + + // Verify the child process has CORE_SOCKET in its environment + sc.mu.RLock() + env := sc.cmd.Env + sc.mu.RUnlock() + + found := false + expected := "CORE_SOCKET=" + sockPath + for _, e := range env { + if e == expected { + found = true + break + } + } + assert.True(t, found, "child process should receive CORE_SOCKET=%s", sockPath) +} + func TestSocketDirCreated_Good(t *testing.T) { dir := t.TempDir() sockPath := filepath.Join(dir, "sub", "deno.sock") diff --git a/pkg/coredeno/listener.go b/pkg/coredeno/listener.go new file mode 100644 index 0000000..5a2a5e2 --- /dev/null +++ b/pkg/coredeno/listener.go @@ -0,0 +1,47 @@ +package coredeno + +import ( + "context" + "net" + "os" + + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + "google.golang.org/grpc" +) + +// ListenGRPC starts a gRPC server on a Unix socket, serving the CoreService. +// It blocks until ctx is cancelled, then performs a graceful stop. +func ListenGRPC(ctx context.Context, socketPath string, srv *Server) error { + // Clean up stale socket + if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { + return err + } + + listener, err := net.Listen("unix", socketPath) + if err != nil { + return err + } + defer func() { + _ = listener.Close() + _ = os.Remove(socketPath) + }() + + gs := grpc.NewServer() + pb.RegisterCoreServiceServer(gs, srv) + + // Graceful stop when context cancelled + go func() { + <-ctx.Done() + gs.GracefulStop() + }() + + if err := gs.Serve(listener); err != nil { + select { + case <-ctx.Done(): + return nil // Expected shutdown + default: + return err + } + } + return nil +} diff --git a/pkg/coredeno/listener_test.go b/pkg/coredeno/listener_test.go new file mode 100644 index 0000000..1ab7f77 --- /dev/null +++ b/pkg/coredeno/listener_test.go @@ -0,0 +1,112 @@ +package coredeno + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" + "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestListenGRPC_Good(t *testing.T) { + sockDir := t.TempDir() + sockPath := filepath.Join(sockDir, "test.sock") + + medium := io.NewMockMedium() + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + srv := NewServer(medium, st) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { + errCh <- ListenGRPC(ctx, sockPath, srv) + }() + + // Wait for socket to appear + require.Eventually(t, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }, 2*time.Second, 10*time.Millisecond, "socket should appear") + + // Connect as gRPC client + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + client := pb.NewCoreServiceClient(conn) + + // StoreSet + StoreGet round-trip + _, err = client.StoreSet(ctx, &pb.StoreSetRequest{ + Group: "test", Key: "k", Value: "v", + }) + require.NoError(t, err) + + resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "test", Key: "k", + }) + require.NoError(t, err) + assert.True(t, resp.Found) + assert.Equal(t, "v", resp.Value) + + // Cancel ctx to stop listener + cancel() + + select { + case err := <-errCh: + assert.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("listener did not stop") + } +} + +func TestListenGRPC_Bad_StaleSocket(t *testing.T) { + sockDir := t.TempDir() + sockPath := filepath.Join(sockDir, "stale.sock") + + // Create a stale socket file + require.NoError(t, os.WriteFile(sockPath, []byte("stale"), 0644)) + + medium := io.NewMockMedium() + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + srv := NewServer(medium, st) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { + errCh <- ListenGRPC(ctx, sockPath, srv) + }() + + // Should replace stale file and start listening + require.Eventually(t, func() bool { + info, err := os.Stat(sockPath) + if err != nil { + return false + } + // Socket file type, not regular file + return info.Mode()&os.ModeSocket != 0 + }, 2*time.Second, 10*time.Millisecond, "socket should replace stale file") + + cancel() + <-errCh +} diff --git a/pkg/coredeno/runtime/main.ts b/pkg/coredeno/runtime/main.ts new file mode 100644 index 0000000..c1c6f93 --- /dev/null +++ b/pkg/coredeno/runtime/main.ts @@ -0,0 +1,30 @@ +// CoreDeno Runtime Entry Point +// Connects to CoreGO via gRPC over Unix socket. +// Implements DenoService for module lifecycle management. + +const socketPath = Deno.env.get("CORE_SOCKET"); +if (!socketPath) { + console.error("FATAL: CORE_SOCKET environment variable not set"); + Deno.exit(1); +} + +console.error(`CoreDeno: connecting to ${socketPath}`); + +// Tier 1: signal readiness and stay alive. +// Tier 2 adds the gRPC client and DenoService implementation. +console.error("CoreDeno: ready"); + +// Keep alive until parent sends SIGTERM +const ac = new AbortController(); +Deno.addSignalListener("SIGTERM", () => { + console.error("CoreDeno: shutting down"); + ac.abort(); +}); + +try { + await new Promise((_resolve, reject) => { + ac.signal.addEventListener("abort", () => reject(new Error("shutdown"))); + }); +} catch { + // Clean exit on SIGTERM +} diff --git a/pkg/coredeno/service.go b/pkg/coredeno/service.go index e218a2e..9bc4d85 100644 --- a/pkg/coredeno/service.go +++ b/pkg/coredeno/service.go @@ -2,8 +2,12 @@ package coredeno import ( "context" + "fmt" 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/store" ) // Service wraps the CoreDeno sidecar as a framework service. @@ -14,7 +18,11 @@ import ( // core.New(core.WithService(coredeno.NewServiceFactory(opts))) type Service struct { *core.ServiceRuntime[Options] - sidecar *Sidecar + sidecar *Sidecar + grpcServer *Server + store *store.Store + grpcCancel context.CancelFunc + grpcDone chan error } // NewServiceFactory returns a factory function for framework registration via WithService. @@ -27,17 +35,95 @@ func NewServiceFactory(opts Options) func(*core.Core) (any, error) { } } -// OnStartup starts the Deno sidecar. Called by the framework on app startup. +// OnStartup boots the CoreDeno subsystem. Called by the framework on app startup. +// +// Sequence: medium → store → server → manifest → gRPC listener → sidecar. func (s *Service) OnStartup(ctx context.Context) error { + opts := s.Opts() + + // 1. Create sandboxed Medium (or mock if no AppRoot) + var medium io.Medium + if opts.AppRoot != "" { + var err error + medium, err = io.NewSandboxed(opts.AppRoot) + if err != nil { + return fmt.Errorf("coredeno: medium: %w", err) + } + } else { + medium = io.NewMockMedium() + } + + // 2. Create Store + dbPath := opts.StoreDBPath + if dbPath == "" { + dbPath = ":memory:" + } + var err error + s.store, err = store.New(dbPath) + if err != nil { + return fmt.Errorf("coredeno: store: %w", err) + } + + // 3. Create gRPC Server + s.grpcServer = NewServer(medium, s.store) + + // 4. Load manifest if AppRoot set (non-fatal if missing) + if opts.AppRoot != "" { + m, loadErr := manifest.Load(medium, ".") + if loadErr == nil && m != nil { + if opts.PublicKey != nil { + if ok, verr := manifest.Verify(m, opts.PublicKey); verr == nil && ok { + s.grpcServer.RegisterModule(m) + } + } else { + s.grpcServer.RegisterModule(m) + } + } + } + + // 5. Start gRPC listener in background + grpcCtx, grpcCancel := context.WithCancel(ctx) + s.grpcCancel = grpcCancel + s.grpcDone = make(chan error, 1) + go func() { + s.grpcDone <- ListenGRPC(grpcCtx, opts.SocketPath, s.grpcServer) + }() + + // 6. Start sidecar (if args provided) + if len(opts.SidecarArgs) > 0 { + if err := s.sidecar.Start(ctx, opts.SidecarArgs...); err != nil { + return fmt.Errorf("coredeno: sidecar: %w", err) + } + } + return nil } -// OnShutdown stops the Deno sidecar. Called by the framework on app shutdown. +// OnShutdown stops the CoreDeno subsystem. Called by the framework on app shutdown. func (s *Service) OnShutdown(_ context.Context) error { - return s.sidecar.Stop() + // Stop sidecar first + _ = s.sidecar.Stop() + + // Stop gRPC listener + if s.grpcCancel != nil { + s.grpcCancel() + <-s.grpcDone + } + + // Close store + if s.store != nil { + s.store.Close() + } + + return nil } // Sidecar returns the underlying sidecar for direct access. func (s *Service) Sidecar() *Sidecar { return s.sidecar } + +// GRPCServer returns the gRPC server for direct access. +func (s *Service) GRPCServer() *Server { + return s.grpcServer +} diff --git a/pkg/coredeno/service_test.go b/pkg/coredeno/service_test.go index 685bbc8..008a96b 100644 --- a/pkg/coredeno/service_test.go +++ b/pkg/coredeno/service_test.go @@ -2,11 +2,17 @@ package coredeno import ( "context" + "os" + "path/filepath" "testing" + "time" + pb "forge.lthn.ai/core/go/pkg/coredeno/proto" core "forge.lthn.ai/core/go/pkg/framework/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" ) func TestNewServiceFactory_Good(t *testing.T) { @@ -37,18 +43,27 @@ func TestService_WithService_Good(t *testing.T) { } func TestService_Lifecycle_Good(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "lifecycle.sock") + c, err := core.New() require.NoError(t, err) - factory := NewServiceFactory(Options{DenoPath: "echo"}) + factory := NewServiceFactory(Options{ + DenoPath: "echo", + SocketPath: sockPath, + }) result, _ := factory(c) svc := result.(*Service) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Verify Startable - err = svc.OnStartup(context.Background()) + err = svc.OnStartup(ctx) assert.NoError(t, err) - // Verify Stoppable (not started, should be no-op) + // Verify Stoppable err = svc.OnShutdown(context.Background()) assert.NoError(t, err) } @@ -63,3 +78,106 @@ func TestService_Sidecar_Good(t *testing.T) { assert.NotNil(t, svc.Sidecar()) } + +func TestService_OnStartup_Good(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + + // Write a minimal 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: test-app +name: Test App +version: "1.0" +permissions: + read: ["./data/"] + write: ["./data/"] +`), 0644)) + + opts := Options{ + DenoPath: "sleep", + SocketPath: sockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + SidecarArgs: []string{"60"}, + } + + 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.WithCancel(context.Background()) + defer cancel() + + err = svc.OnStartup(ctx) + require.NoError(t, err) + + // Verify socket appeared + require.Eventually(t, func() bool { + _, err := os.Stat(sockPath) + return err == nil + }, 2*time.Second, 10*time.Millisecond, "gRPC socket should appear after startup") + + // Verify gRPC responds + conn, err := grpc.NewClient( + "unix://"+sockPath, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer conn.Close() + + client := pb.NewCoreServiceClient(conn) + _, err = client.StoreSet(ctx, &pb.StoreSetRequest{ + Group: "boot", Key: "ok", Value: "true", + }) + require.NoError(t, err) + + resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{ + Group: "boot", Key: "ok", + }) + require.NoError(t, err) + assert.True(t, resp.Found) + assert.Equal(t, "true", resp.Value) + + // Verify sidecar is running + assert.True(t, svc.sidecar.IsRunning(), "sidecar should be running") + + // Shutdown + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) + assert.False(t, svc.sidecar.IsRunning(), "sidecar should be stopped") +} + +func TestService_OnStartup_Good_NoManifest(t *testing.T) { + tmpDir := t.TempDir() + sockPath := filepath.Join(tmpDir, "core.sock") + + opts := Options{ + DenoPath: "sleep", + SocketPath: sockPath, + AppRoot: tmpDir, + StoreDBPath: ":memory:", + } + + c, err := core.New() + require.NoError(t, err) + + factory := NewServiceFactory(opts) + result, _ := factory(c) + svc := result.(*Service) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Should succeed even without .core/view.yml + err = svc.OnStartup(ctx) + require.NoError(t, err) + + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) +}