582b5a6b08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
6.7 KiB
Go
217 lines
6.7 KiB
Go
// 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
|
|
}
|