panel v0.9.2 — public release

Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:43:35 -07:00
commit 4ccccc6fe2
2164 changed files with 301480 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
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/<file> 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},
},
})
}