panel v0.9.2 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:06:16 -07:00
commit 0f6aea796c
2164 changed files with 301480 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
// Package module (agent-side) resolves a manifest + InstanceCreate RPC
// into a runtime.InstanceSpec ready for the Docker / host runtime to launch.
package module
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// ResolveDocker produces an InstanceSpec for Docker-mode launch of the given
// module manifest and RPC create request. It substitutes $DATA_PATH in volume
// targets and maps declared ports against the request's port overrides.
func ResolveDocker(manifest *modulepkg.Manifest, req *panelv1.InstanceCreate) (runtime.InstanceSpec, error) {
if manifest == nil {
return runtime.InstanceSpec{}, errors.New("manifest is nil")
}
if manifest.Runtime.Docker == nil {
return runtime.InstanceSpec{}, fmt.Errorf("module %q has no docker runtime", manifest.ID)
}
if req.DataPath == "" {
return runtime.InstanceSpec{}, errors.New("req.data_path is required")
}
docker := manifest.Runtime.Docker
env := map[string]string{}
for k, v := range docker.Env {
env[k] = expand(v, req.DataPath)
}
// Per-instance config_values layer: if the operator (or controller
// feature code) set a value for an env key the module already
// declares, use that value instead of the manifest default. Only
// keys that ALREADY exist in docker.Env get overridden — we don't
// let config_values smuggle in unrelated env vars. Used today by
// the ARK cluster feature to override CLUSTER_ID per-instance.
for k, v := range req.ConfigValues {
if _, ok := env[k]; ok {
env[k] = v
}
}
volumes := make([]runtime.VolumeSpec, 0, len(docker.Volumes))
for _, v := range docker.Volumes {
vs := runtime.VolumeSpec{
ContainerPath: v.Container,
ReadOnly: v.ReadOnly,
}
kind := v.Type
if kind == "" {
// Infer from which field was populated.
if v.Name != "" {
kind = "volume"
} else {
kind = "bind"
}
}
switch kind {
case "volume":
vs.Type = "volume"
vs.VolumeName = expandInstance(v.Name, req.InstanceId)
default:
vs.Type = "bind"
vs.HostPath = expand(v.Target, req.DataPath)
}
// Apply per-instance mount override if the controller asked for
// one by container path. An absolute path (Unix `/...` or
// Windows `C:\...`) is a bind mount host path; anything else is
// a Docker named volume. Used by the ARK cluster feature to
// swap the default per-instance cluster volume for a shared
// host-visible `arkcluster/<id>/` bind mount. `$AGENT_DATA_ROOT`
// has already been expanded by handleCreate.
if ov, ok := req.MountOverrides[v.Container]; ok && ov != "" {
if filepath.IsAbs(ov) {
vs.Type = "bind"
vs.HostPath = ov
vs.VolumeName = ""
} else {
vs.Type = "volume"
vs.VolumeName = ov
vs.HostPath = ""
}
}
volumes = append(volumes, vs)
}
portOverrides := indexPortOverrides(req.Ports)
ports := make([]runtime.PortSpec, 0, len(manifest.Ports))
for _, p := range manifest.Ports {
containerPort := uint16(p.Default)
hostPort := containerPort
if ov, ok := portOverrides[p.Name]; ok {
if ov.ContainerPort != 0 {
containerPort = uint16(ov.ContainerPort)
}
if ov.HostPort != 0 {
hostPort = uint16(ov.HostPort)
}
}
// Internal ports (e.g. 7DTD's telnet RCON on 8081) must still be
// reachable from the Target agent — it runs on the same host — so we
// bind them to 127.0.0.1 instead of 0.0.0.0. Public ports bind to
// all interfaces (HostIP left empty).
hostIP := ""
if p.Internal {
hostIP = "127.0.0.1"
}
ports = append(ports, runtime.PortSpec{
ContainerPort: containerPort,
HostPort: hostPort,
Proto: strings.ToLower(p.Proto),
HostIP: hostIP,
})
}
// Preserve nil when the manifest doesn't override — passing []string{} to
// Docker ContainerCreate replaces the image's default entrypoint/cmd with
// empty, which errors out as "no command specified".
var entrypoint, command []string
if len(docker.Entrypoint) > 0 {
entrypoint = append([]string{}, docker.Entrypoint...)
}
if len(docker.Command) > 0 {
command = append([]string{}, docker.Command...)
}
spec := runtime.InstanceSpec{
InstanceID: req.InstanceId,
Image: docker.Image,
Entrypoint: entrypoint,
Command: command,
Env: env,
User: docker.User,
Volumes: volumes,
Ports: ports,
NetworkMode: docker.NetworkMode,
BuildContext: manifest.Dir, // module's directory holds the Dockerfile
}
// Auto-enable OpenStdin whenever the module's RCON adapter is stdio —
// it's the one feature the adapter needs from the runtime, and making
// it implicit saves a second knob every stdio-based module would have
// to remember to set.
if manifest.RCON != nil && manifest.RCON.Adapter == "stdio" {
spec.OpenStdin = true
}
if lim := req.Limits; lim != nil {
spec.Resources = runtime.ResourceLimits{
CPUShares: lim.CpuShares,
MemoryBytes: lim.MemBytes,
}
}
return spec, nil
}
// expand substitutes $DATA_PATH in the given string.
func expand(s, dataPath string) string {
return strings.ReplaceAll(s, "$DATA_PATH", dataPath)
}
// expandInstance substitutes $INSTANCE_ID in the given string. Used for
// named-volume templating so each instance gets its own volume.
func expandInstance(s, instanceID string) string {
return strings.ReplaceAll(s, "$INSTANCE_ID", instanceID)
}
// ResolveVolumes translates manifest volumes into runtime.VolumeSpec form
// for a specific instance. Exposed so sidecar launchers (SteamCMD updater,
// future backup-runner, etc.) can mount the same volumes the main
// container uses, putting files where the main container expects them.
func ResolveVolumes(manifest *modulepkg.Manifest, instanceID, dataPath string) []runtime.VolumeSpec {
if manifest == nil || manifest.Runtime.Docker == nil {
return nil
}
out := make([]runtime.VolumeSpec, 0, len(manifest.Runtime.Docker.Volumes))
for _, v := range manifest.Runtime.Docker.Volumes {
vs := runtime.VolumeSpec{
ContainerPath: v.Container,
ReadOnly: v.ReadOnly,
}
kind := v.Type
if kind == "" {
if v.Name != "" {
kind = "volume"
} else {
kind = "bind"
}
}
switch kind {
case "volume":
vs.Type = "volume"
vs.VolumeName = expandInstance(v.Name, instanceID)
default:
vs.Type = "bind"
vs.HostPath = expand(v.Target, dataPath)
}
out = append(out, vs)
}
return out
}
// indexPortOverrides builds a by-name lookup of PortMap entries from the RPC.
func indexPortOverrides(ports []*panelv1.PortMap) map[string]*panelv1.PortMap {
out := map[string]*panelv1.PortMap{}
for _, p := range ports {
if p == nil {
continue
}
out[p.Name] = p
}
return out
}