Files
DoGaMa-serv/internal/instance/module_service.go
T
codex 75d5504729
CI / validate (pull_request) Canceled after 3m32s
refactor(modules): load wasm modules from templates
2026-08-25 17:52:28 +02:00

252 lines
8.0 KiB
Go

package instance
// This file is the application boundary between persisted instances and WASM
// integrations. It deliberately owns all manifest/artifact/secrets handling;
// HTTP handlers only use the typed methods below.
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/module"
"gopkg.in/yaml.v3"
)
var ErrModuleUnavailable = errors.New("module unavailable")
type ModuleService struct {
secrets SecretRepository
catalog catalog.Repository
}
func NewModuleService(secrets SecretRepository, repository catalog.Repository) *ModuleService {
return &ModuleService{secrets: secrets, catalog: repository}
}
type ServerInfo struct {
Name string `json:"name"`
GameVersion string `json:"game_version"`
Description string `json:"description"`
WorldID string `json:"world_id"`
}
type Metrics struct {
CurrentPlayers int `json:"current_players"`
MaxPlayers int `json:"max_players"`
}
type Player struct {
PlayerID string `json:"player_id"`
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
PingMS float64 `json:"ping_ms"`
}
// Ban is a normalized, stable selection value returned by an adapter. The
// identifier, rather than the display name, is sent back for an unban action.
type Ban struct {
PlayerID string `json:"player_id"`
DisplayName string `json:"display_name"`
}
type Live struct {
Capabilities map[string]bool
ServerInfo *ServerInfo
Metrics *Metrics
Players []Player
Bans []Ban
BansChecked bool
Unavailable bool
}
type moduleManifest struct {
ID string `yaml:"id"`
Runtime struct {
Type string `yaml:"type"`
ABI string `yaml:"abi"`
} `yaml:"runtime"`
Compatibility struct {
ManagerAPI string `yaml:"manager_api"`
ModuleAPI string `yaml:"module_api"`
} `yaml:"compatibility"`
Capabilities []string `yaml:"capabilities"`
Permissions struct {
Network struct {
PortIDs []string `yaml:"port_ids"`
HTTPMethods []string `yaml:"http_methods"`
} `yaml:"network"`
} `yaml:"permissions"`
Limits struct {
MemoryMB uint32 `yaml:"memory_mb"`
TimeoutMS int `yaml:"timeout_ms"`
MaxResponseBytes int `yaml:"max_response_bytes"`
MaxConcurrentCalls int `yaml:"max_concurrent_calls"`
} `yaml:"limits"`
Configuration []struct {
ID string `yaml:"id"`
Type string `yaml:"type"`
} `yaml:"configuration"`
Artifacts struct {
WASM string `yaml:"wasm"`
SHA256 string `yaml:"sha256"`
} `yaml:"artifacts"`
}
func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*module.Runtime, moduleManifest, error) {
if s == nil || s.secrets == nil || s.catalog == nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
snapshot, err := s.catalog.Get(ctx, value.Preview.Template.ID, value.Preview.Template.Version)
if err != nil || snapshot.Template.Integration == nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
integration := snapshot.Template.Integration
if integration.ModuleID == "" || snapshot.Template.Module == nil || !validTemplateModulePath(snapshot.Template.Module.Path) {
return nil, moduleManifest{}, ErrModuleUnavailable
}
body, ok := snapshot.ModuleFiles[snapshot.Template.Module.Path]
if !ok {
return nil, moduleManifest{}, ErrModuleUnavailable
}
var manifest moduleManifest
if yaml.Unmarshal(body, &manifest) != nil || manifest.ID != integration.ModuleID || manifest.Runtime.Type != "wasm" || manifest.Runtime.ABI != module.ABI || manifest.Compatibility.ManagerAPI == "" || manifest.Compatibility.ModuleAPI == "" {
return nil, moduleManifest{}, ErrModuleUnavailable
}
var port int
for _, p := range value.Preview.Ports {
if p.ID == integration.PortID && p.Purpose == "integration" {
port = p.ContainerPort
break
}
}
if port == 0 || !contains(manifest.Permissions.Network.PortIDs, integration.PortID) {
return nil, moduleManifest{}, ErrModuleUnavailable
}
methods := map[string]bool{}
for _, method := range manifest.Permissions.Network.HTTPMethods {
if method == "GET" || method == "POST" {
methods[method] = true
} else {
return nil, moduleManifest{}, ErrModuleUnavailable
}
}
if len(methods) == 0 || manifest.Artifacts.WASM == "" || strings.Contains(manifest.Artifacts.WASM, "/") || strings.Contains(manifest.Artifacts.WASM, "..") {
return nil, moduleManifest{}, ErrModuleUnavailable
}
config, secrets := map[string]string{}, map[string]string{}
stored, err := s.secrets.LoadInstanceSecrets(ctx, value.ID)
if err != nil {
return nil, moduleManifest{}, ErrModuleUnavailable
}
for _, field := range manifest.Configuration {
if field.ID == "" {
return nil, moduleManifest{}, ErrModuleUnavailable
}
if field.Type == "secret" {
if v := stored[field.ID]; v != "" {
secrets[field.ID] = v
}
} else if v := value.Preview.Configuration[field.ID]; v != "" {
config[field.ID] = v
}
}
wasm, ok := snapshot.ModuleFiles["module/"+manifest.Artifacts.WASM]
if !ok {
return nil, moduleManifest{}, ErrModuleUnavailable
}
r, err := module.New(ctx, wasm, manifest.Artifacts.SHA256, manifest.Capabilities, module.Limits{MemoryMB: manifest.Limits.MemoryMB, Timeout: durationMS(manifest.Limits.TimeoutMS), MaxResponseBytes: manifest.Limits.MaxResponseBytes, MaxConcurrentCall: manifest.Limits.MaxConcurrentCalls}, module.Binding{InstanceID: value.ID, ContainerPort: port, AllowedMethods: methods, Configuration: config, Secrets: secrets})
if err != nil {
return nil, moduleManifest{}, fmt.Errorf("%w: invalid integration", ErrModuleUnavailable)
}
return r, manifest, nil
}
func durationMS(v int) time.Duration { return time.Duration(v) * time.Millisecond }
func validTemplateModulePath(value string) bool {
return strings.HasPrefix(value, "module/") && !strings.Contains(value, "..") && !strings.HasPrefix(value, "/")
}
func contains(values []string, needle string) bool {
for _, v := range values {
if v == needle {
return true
}
}
return false
}
func (s *ModuleService) Live(ctx context.Context, value StoredInstance, players bool) Live {
r, manifest, err := s.runtime(ctx, value)
if err != nil {
return Live{Unavailable: true}
}
live := Live{Capabilities: map[string]bool{}}
for _, c := range manifest.Capabilities {
live.Capabilities[c] = true
}
if live.Capabilities["server_info"] {
var out ServerInfo
if r.Call(ctx, "get_server_info", struct{}{}, &out) == nil {
live.ServerInfo = &out
}
}
if live.Capabilities["metrics"] {
var out Metrics
if r.Call(ctx, "get_metrics", struct{}{}, &out) == nil {
live.Metrics = &out
}
}
if players && live.Capabilities["player_list"] {
var out struct {
Players []Player `json:"players"`
}
if r.Call(ctx, "list_players", struct{}{}, &out) == nil {
live.Players = out.Players
}
}
if live.Capabilities["list_bans"] {
live.BansChecked = true
var out struct {
Bans []Ban `json:"bans"`
}
if r.Call(ctx, "list_bans", struct{}{}, &out) == nil {
live.Bans = out.Bans
} else {
live.BansChecked = false
}
}
return live
}
// ListBans is deliberately separate from Live so POST validation always uses
// a fresh runtime result rather than browser-rendered values.
func (s *ModuleService) ListBans(ctx context.Context, value StoredInstance) ([]Ban, error) {
r, manifest, err := s.runtime(ctx, value)
if err != nil || !contains(manifest.Capabilities, "list_bans") {
return nil, ErrModuleUnavailable
}
var out struct {
Bans []Ban `json:"bans"`
}
if err := r.Call(ctx, "list_bans", struct{}{}, &out); err != nil {
return nil, ErrModuleUnavailable
}
return out.Bans, nil
}
func (s *ModuleService) Action(ctx context.Context, value StoredInstance, capability, operation string, request any) error {
r, manifest, err := s.runtime(ctx, value)
if err != nil || !contains(manifest.Capabilities, capability) {
return ErrModuleUnavailable
}
var out struct {
Accepted bool `json:"accepted"`
}
if err := r.Call(ctx, operation, request, &out); err != nil || !out.Accepted {
return ErrModuleUnavailable
}
return nil
}