feat(coredeno): wire Tier 1 boot sequence — gRPC listener, manifest loading, sidecar launch
Service.OnStartup now creates sandboxed I/O medium, opens SQLite store, starts gRPC listener on Unix socket, loads .core/view.yml manifest, and launches Deno sidecar with CORE_SOCKET env var. Full shutdown in reverse. New files: listener.go (Unix socket gRPC server), runtime/main.ts (Deno entry point), integration_test.go (full boot with real Deno). 34 tests pass (33 unit + 1 integration). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d047fbdcc
commit
2f246ad053
12 changed files with 590 additions and 25 deletions
4
go.mod
4
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
|
||||
|
|
|
|||
21
go.sum
21
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=
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package coredeno
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
|
@ -14,6 +15,10 @@ import (
|
|||
type Options struct {
|
||||
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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
110
pkg/coredeno/integration_test.go
Normal file
110
pkg/coredeno/integration_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
47
pkg/coredeno/listener.go
Normal file
47
pkg/coredeno/listener.go
Normal file
|
|
@ -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
|
||||
}
|
||||
112
pkg/coredeno/listener_test.go
Normal file
112
pkg/coredeno/listener_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
30
pkg/coredeno/runtime/main.ts
Normal file
30
pkg/coredeno/runtime/main.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -15,6 +19,10 @@ import (
|
|||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue