268 lines
6.7 KiB
Go
268 lines
6.7 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"path/filepath"
|
||
|
|
"strconv"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"forge.lthn.ai/core/api"
|
||
|
|
"forge.lthn.ai/core/api/pkg/provider"
|
||
|
|
"forge.lthn.ai/core/go-scm/manifest"
|
||
|
|
"forge.lthn.ai/core/go-scm/marketplace"
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
)
|
||
|
|
|
||
|
|
// RuntimeProvider represents a running provider process with its proxy.
|
||
|
|
type RuntimeProvider struct {
|
||
|
|
Dir string
|
||
|
|
Manifest *manifest.Manifest
|
||
|
|
Port int
|
||
|
|
Cmd *exec.Cmd
|
||
|
|
}
|
||
|
|
|
||
|
|
// RuntimeManager discovers, starts, and stops runtime provider processes.
|
||
|
|
// Each provider runs as a separate binary on 127.0.0.1, reverse-proxied
|
||
|
|
// through the IDE's Gin router via ProxyProvider.
|
||
|
|
type RuntimeManager struct {
|
||
|
|
engine *api.Engine
|
||
|
|
providers []*RuntimeProvider
|
||
|
|
mu sync.Mutex
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewRuntimeManager creates a RuntimeManager.
|
||
|
|
func NewRuntimeManager(engine *api.Engine) *RuntimeManager {
|
||
|
|
return &RuntimeManager{
|
||
|
|
engine: engine,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// defaultProvidersDir returns ~/.core/providers/.
|
||
|
|
func defaultProvidersDir() string {
|
||
|
|
home, err := os.UserHomeDir()
|
||
|
|
if err != nil {
|
||
|
|
home = os.TempDir()
|
||
|
|
}
|
||
|
|
return filepath.Join(home, ".core", "providers")
|
||
|
|
}
|
||
|
|
|
||
|
|
// StartAll discovers providers in ~/.core/providers/ and starts each one.
|
||
|
|
// Providers that fail to start are logged and skipped — they do not prevent
|
||
|
|
// other providers from starting.
|
||
|
|
func (rm *RuntimeManager) StartAll(ctx context.Context) error {
|
||
|
|
rm.mu.Lock()
|
||
|
|
defer rm.mu.Unlock()
|
||
|
|
|
||
|
|
dir := defaultProvidersDir()
|
||
|
|
discovered, err := marketplace.DiscoverProviders(dir)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("runtime: discover providers: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(discovered) == 0 {
|
||
|
|
log.Println("runtime: no providers found in", dir)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
log.Printf("runtime: discovered %d provider(s) in %s", len(discovered), dir)
|
||
|
|
|
||
|
|
for _, dp := range discovered {
|
||
|
|
rp, err := rm.startProvider(ctx, dp)
|
||
|
|
if err != nil {
|
||
|
|
log.Printf("runtime: failed to start %s: %v", dp.Manifest.Code, err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
rm.providers = append(rm.providers, rp)
|
||
|
|
log.Printf("runtime: started %s on port %d", dp.Manifest.Code, rp.Port)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// startProvider starts a single provider binary and registers its proxy.
|
||
|
|
func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.DiscoveredProvider) (*RuntimeProvider, error) {
|
||
|
|
m := dp.Manifest
|
||
|
|
|
||
|
|
// Assign a free port.
|
||
|
|
port, err := findFreePort()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("find free port: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resolve binary path.
|
||
|
|
binaryPath := m.Binary
|
||
|
|
if !filepath.IsAbs(binaryPath) {
|
||
|
|
binaryPath = filepath.Join(dp.Dir, binaryPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build command args.
|
||
|
|
args := make([]string, len(m.Args))
|
||
|
|
copy(args, m.Args)
|
||
|
|
args = append(args, "--namespace", m.Namespace, "--port", strconv.Itoa(port))
|
||
|
|
|
||
|
|
// Start the process.
|
||
|
|
cmd := exec.CommandContext(ctx, binaryPath, args...)
|
||
|
|
cmd.Dir = dp.Dir
|
||
|
|
cmd.Stdout = os.Stdout
|
||
|
|
cmd.Stderr = os.Stderr
|
||
|
|
|
||
|
|
if err := cmd.Start(); err != nil {
|
||
|
|
return nil, fmt.Errorf("start binary %s: %w", binaryPath, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wait for health check.
|
||
|
|
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", port)
|
||
|
|
if err := waitForHealth(healthURL, 10*time.Second); err != nil {
|
||
|
|
// Kill the process if health check fails.
|
||
|
|
_ = cmd.Process.Kill()
|
||
|
|
return nil, fmt.Errorf("health check failed for %s: %w", m.Code, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Register proxy provider.
|
||
|
|
cfg := provider.ProxyConfig{
|
||
|
|
Name: m.Code,
|
||
|
|
BasePath: m.Namespace,
|
||
|
|
Upstream: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||
|
|
}
|
||
|
|
if m.Element != nil {
|
||
|
|
cfg.Element = provider.ElementSpec{
|
||
|
|
Tag: m.Element.Tag,
|
||
|
|
Source: m.Element.Source,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if m.Spec != "" {
|
||
|
|
cfg.SpecFile = filepath.Join(dp.Dir, m.Spec)
|
||
|
|
}
|
||
|
|
|
||
|
|
proxy := provider.NewProxy(cfg)
|
||
|
|
rm.engine.Register(proxy)
|
||
|
|
|
||
|
|
// Serve JS assets if the provider has an element source.
|
||
|
|
if m.Element != nil && m.Element.Source != "" {
|
||
|
|
assetsDir := filepath.Join(dp.Dir, "assets")
|
||
|
|
if _, err := os.Stat(assetsDir); err == nil {
|
||
|
|
// Assets are served at /assets/{code}/
|
||
|
|
rm.engine.Register(&staticAssetGroup{
|
||
|
|
name: m.Code + "-assets",
|
||
|
|
basePath: "/assets/" + m.Code,
|
||
|
|
dir: assetsDir,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
rp := &RuntimeProvider{
|
||
|
|
Dir: dp.Dir,
|
||
|
|
Manifest: m,
|
||
|
|
Port: port,
|
||
|
|
Cmd: cmd,
|
||
|
|
}
|
||
|
|
|
||
|
|
return rp, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// StopAll terminates all running provider processes.
|
||
|
|
func (rm *RuntimeManager) StopAll() {
|
||
|
|
rm.mu.Lock()
|
||
|
|
defer rm.mu.Unlock()
|
||
|
|
|
||
|
|
for _, rp := range rm.providers {
|
||
|
|
if rp.Cmd != nil && rp.Cmd.Process != nil {
|
||
|
|
log.Printf("runtime: stopping %s (pid %d)", rp.Manifest.Code, rp.Cmd.Process.Pid)
|
||
|
|
_ = rp.Cmd.Process.Signal(os.Interrupt)
|
||
|
|
|
||
|
|
// Give the process 5 seconds to exit gracefully.
|
||
|
|
done := make(chan error, 1)
|
||
|
|
go func() { done <- rp.Cmd.Wait() }()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case <-done:
|
||
|
|
// Exited cleanly.
|
||
|
|
case <-time.After(5 * time.Second):
|
||
|
|
_ = rp.Cmd.Process.Kill()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
rm.providers = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// List returns a copy of all running provider info.
|
||
|
|
func (rm *RuntimeManager) List() []RuntimeProviderInfo {
|
||
|
|
rm.mu.Lock()
|
||
|
|
defer rm.mu.Unlock()
|
||
|
|
|
||
|
|
infos := make([]RuntimeProviderInfo, 0, len(rm.providers))
|
||
|
|
for _, rp := range rm.providers {
|
||
|
|
infos = append(infos, RuntimeProviderInfo{
|
||
|
|
Code: rp.Manifest.Code,
|
||
|
|
Name: rp.Manifest.Name,
|
||
|
|
Version: rp.Manifest.Version,
|
||
|
|
Namespace: rp.Manifest.Namespace,
|
||
|
|
Port: rp.Port,
|
||
|
|
Dir: rp.Dir,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return infos
|
||
|
|
}
|
||
|
|
|
||
|
|
// RuntimeProviderInfo is a serialisable summary of a running provider.
|
||
|
|
type RuntimeProviderInfo struct {
|
||
|
|
Code string `json:"code"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
Version string `json:"version"`
|
||
|
|
Namespace string `json:"namespace"`
|
||
|
|
Port int `json:"port"`
|
||
|
|
Dir string `json:"dir"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// findFreePort asks the OS for an available TCP port on 127.0.0.1.
|
||
|
|
func findFreePort() (int, error) {
|
||
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
defer l.Close()
|
||
|
|
return l.Addr().(*net.TCPAddr).Port, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// waitForHealth polls a health URL until it returns 200 or the timeout expires.
|
||
|
|
func waitForHealth(url string, timeout time.Duration) error {
|
||
|
|
deadline := time.Now().Add(timeout)
|
||
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
||
|
|
|
||
|
|
for time.Now().Before(deadline) {
|
||
|
|
resp, err := client.Get(url)
|
||
|
|
if err == nil {
|
||
|
|
resp.Body.Close()
|
||
|
|
if resp.StatusCode == http.StatusOK {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
time.Sleep(100 * time.Millisecond)
|
||
|
|
}
|
||
|
|
|
||
|
|
return fmt.Errorf("health check timed out after %s: %s", timeout, url)
|
||
|
|
}
|
||
|
|
|
||
|
|
// staticAssetGroup is a simple RouteGroup that serves static files.
|
||
|
|
// Used to serve provider JS assets.
|
||
|
|
type staticAssetGroup struct {
|
||
|
|
name string
|
||
|
|
basePath string
|
||
|
|
dir string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *staticAssetGroup) Name() string { return s.name }
|
||
|
|
func (s *staticAssetGroup) BasePath() string { return s.basePath }
|
||
|
|
|
||
|
|
func (s *staticAssetGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||
|
|
rg.Static("/", s.dir)
|
||
|
|
}
|