package dispatch import ( "bytes" "context" "os" "path/filepath" "google.golang.org/protobuf/types/known/timestamppb" agentmodule "github.com/dbledeez/panel/agent/internal/module" modulepkg "github.com/dbledeez/panel/pkg/module" panelv1 "github.com/dbledeez/panel/proto/panel/v1" ) // handleRenderConfig re-renders the module's declared config_files into the // instance's host DataPath — the same modulepkg.Render call handleCreate // makes, minus the container recreate. For modules that bind-mount the // rendered output into the container (7dtd's serverconfig.xml.rendered), // os.WriteFile rewrites the host file in place (same inode), so the running // container sees the new content immediately and the entrypoint applies it // on the NEXT boot. The container itself is never restarted. // // Containers created BEFORE a config_file's bind-mount was added to the // module manifest don't have the bind at all (docker pins mounts at create) // — their /game-saves/ is a plain file inside the saves volume. For // those we self-verify: read the file back through the container, and if it // doesn't match the fresh render, copy the rendered bytes in directly // (works running or stopped; the game only reads config at boot). func (d *Dispatcher) handleRenderConfig(ctx context.Context, corrID string, req *panelv1.InstanceRenderConfigRequest) { log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID) fail := func(msg string) { d.sendEnv(&panelv1.AgentEnvelope{ CorrelationId: corrID, SentAt: timestamppb.Now(), Payload: &panelv1.AgentEnvelope_InstanceRenderConfigResult{ InstanceRenderConfigResult: &panelv1.InstanceRenderConfigResult{Error: msg}, }, }) log.Warn("render config failed", "err", msg) } rec, err := d.lookupRecord(req.InstanceId) if err != nil { fail(err.Error()) return } if rec.DataPath == "" { fail("instance has no data path (pre-feature sidecar?) — recreate once to adopt") return } manifest, ok := d.modules.Get(rec.ModuleID) if !ok { fail("module " + rec.ModuleID + " not in registry") return } // Same merge handleCreate uses: generated secrets first, user values on // top (empties dropped → template `or` defaults kick in). values, err := mergeValuesAndSecrets(manifest, req.ConfigValues) if err != nil { fail("secrets: " + err.Error()) return } // Branch-aware: a 3.0 instance renders serverconfig.v3.xml.tmpl, ≤2.6 // keeps the default. Resolve from the instance's persisted branch (set at // create), falling back to the merged config_values' provider_id so a // durable config-render still picks the right template if the record // predates branch persistence. branch := rec.Branch if branch == "" { branch = resolveCreateBranch(manifest, values) } if err := modulepkg.RenderForBranch(manifest, rec.DataPath, values, branch); err != nil { fail("render: " + err.Error()) return } // Self-verify through the container; fall back to a direct copy into // the volume for pre-bind containers. The mapping host-path → container // path comes from the manifest's volume specs. volumes := agentmodule.ResolveVolumes(manifest, rec.InstanceID, rec.DataPath) container := "panel-" + rec.InstanceID var rendered []string for _, cf := range manifest.ConfigFiles { if cf.TemplateForBranch(branch) == "" { continue } rendered = append(rendered, cf.Path) hostPath := filepath.Join(rec.DataPath, cf.Path) ctrPath := "" for _, v := range volumes { if v.Type == "bind" && v.HostPath == hostPath { ctrPath = v.ContainerPath break } } if ctrPath == "" { continue // file isn't mounted into the container; host render is all there is } want, rerr := os.ReadFile(hostPath) if rerr != nil { fail("read rendered " + cf.Path + ": " + rerr.Error()) return } got, gerr := d.runtime.CopyFileFromContainer(ctx, container, ctrPath) if gerr == nil && bytes.Equal(got, want) { continue // live bind-mount delivered the new render } // Pre-bind container (or unreadable): write the volume copy directly. if cerr := d.runtime.CopyFileToContainer(ctx, container, ctrPath, want); cerr != nil { fail("container has no live bind for " + cf.Path + " and direct copy failed: " + cerr.Error()) return } log.Info("rendered config copied into pre-bind container volume", "file", cf.Path, "container_path", ctrPath) } log.Info("re-rendered config files (durable, applies next boot)", "files", rendered) d.sendEnv(&panelv1.AgentEnvelope{ CorrelationId: corrID, SentAt: timestamppb.Now(), Payload: &panelv1.AgentEnvelope_InstanceRenderConfigResult{ InstanceRenderConfigResult: &panelv1.InstanceRenderConfigResult{RenderedFiles: rendered}, }, }) }