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/net v0.50.0
|
||||||
golang.org/x/term v0.40.0
|
golang.org/x/term v0.40.0
|
||||||
golang.org/x/text v0.34.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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
@ -113,8 +115,6 @@ require (
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
gonum.org/v1/gonum v0.17.0 // indirect
|
gonum.org/v1/gonum v0.17.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // 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/libc v1.67.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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/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 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
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 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
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=
|
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=
|
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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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 v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
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/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
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 v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
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/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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=
|
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=
|
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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
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 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/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 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package coredeno
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
@ -12,8 +13,12 @@ import (
|
||||||
|
|
||||||
// Options configures the CoreDeno sidecar.
|
// Options configures the CoreDeno sidecar.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
DenoPath string // path to deno binary (default: "deno")
|
DenoPath string // path to deno binary (default: "deno")
|
||||||
SocketPath string // Unix socket path for gRPC
|
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.
|
// Permissions declares per-module Deno permission flags.
|
||||||
|
|
@ -69,5 +74,8 @@ func NewSidecar(opts Options) *Sidecar {
|
||||||
if opts.SocketPath == "" {
|
if opts.SocketPath == "" {
|
||||||
opts.SocketPath = DefaultSocketPath()
|
opts.SocketPath = DefaultSocketPath()
|
||||||
}
|
}
|
||||||
|
if opts.StoreDBPath == "" && opts.AppRoot != "" {
|
||||||
|
opts.StoreDBPath = filepath.Join(opts.AppRoot, ".core", "store.db")
|
||||||
|
}
|
||||||
return &Sidecar{opts: opts}
|
return &Sidecar{opts: opts}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,34 @@ func TestSidecar_PermissionFlags_Empty(t *testing.T) {
|
||||||
assert.Empty(t, flags)
|
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) {
|
func TestDefaultSocketPath_XDG(t *testing.T) {
|
||||||
orig := os.Getenv("XDG_RUNTIME_DIR")
|
orig := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
defer os.Setenv("XDG_RUNTIME_DIR", orig)
|
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.ctx, s.cancel = context.WithCancel(ctx)
|
||||||
s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...)
|
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{})
|
s.done = make(chan struct{})
|
||||||
if err := s.cmd.Start(); err != nil {
|
if err := s.cmd.Start(); err != nil {
|
||||||
s.cmd = 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")
|
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) {
|
func TestSocketDirCreated_Good(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
sockPath := filepath.Join(dir, "sub", "deno.sock")
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
core "forge.lthn.ai/core/go/pkg/framework/core"
|
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.
|
// Service wraps the CoreDeno sidecar as a framework service.
|
||||||
|
|
@ -14,7 +18,11 @@ import (
|
||||||
// core.New(core.WithService(coredeno.NewServiceFactory(opts)))
|
// core.New(core.WithService(coredeno.NewServiceFactory(opts)))
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*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.
|
// 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 {
|
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
|
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 {
|
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.
|
// Sidecar returns the underlying sidecar for direct access.
|
||||||
func (s *Service) Sidecar() *Sidecar {
|
func (s *Service) Sidecar() *Sidecar {
|
||||||
return s.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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
|
||||||
core "forge.lthn.ai/core/go/pkg/framework/core"
|
core "forge.lthn.ai/core/go/pkg/framework/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewServiceFactory_Good(t *testing.T) {
|
func TestNewServiceFactory_Good(t *testing.T) {
|
||||||
|
|
@ -37,18 +43,27 @@ func TestService_WithService_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Lifecycle_Good(t *testing.T) {
|
func TestService_Lifecycle_Good(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
sockPath := filepath.Join(tmpDir, "lifecycle.sock")
|
||||||
|
|
||||||
c, err := core.New()
|
c, err := core.New()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
factory := NewServiceFactory(Options{DenoPath: "echo"})
|
factory := NewServiceFactory(Options{
|
||||||
|
DenoPath: "echo",
|
||||||
|
SocketPath: sockPath,
|
||||||
|
})
|
||||||
result, _ := factory(c)
|
result, _ := factory(c)
|
||||||
svc := result.(*Service)
|
svc := result.(*Service)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Verify Startable
|
// Verify Startable
|
||||||
err = svc.OnStartup(context.Background())
|
err = svc.OnStartup(ctx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify Stoppable (not started, should be no-op)
|
// Verify Stoppable
|
||||||
err = svc.OnShutdown(context.Background())
|
err = svc.OnShutdown(context.Background())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
@ -63,3 +78,106 @@ func TestService_Sidecar_Good(t *testing.T) {
|
||||||
|
|
||||||
assert.NotNil(t, svc.Sidecar())
|
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