// 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//` 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 }