Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/dbledeez/panel/agent/internal/runtime"
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
// handleDelete tears down the instance record. If the container is still
|
||||
// running, it's stopped with the requested grace first. Optionally
|
||||
// removes every Docker volume labeled for this instance ("purge").
|
||||
//
|
||||
// Never deletes the host data_path directory — operators may want to
|
||||
// keep configs/logs that landed on the bind mount. If they want a full
|
||||
// wipe, they can delete the dir manually after.
|
||||
func (d *Dispatcher) handleDelete(ctx context.Context, corrID string, req *panelv1.InstanceDelete) {
|
||||
log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)
|
||||
|
||||
d.mu.Lock()
|
||||
rec, exists := d.instances[req.InstanceId]
|
||||
d.mu.Unlock()
|
||||
|
||||
// Cancel any in-flight steamcmd / updater goroutine FIRST. The
|
||||
// updater spins up its own sidecar container (panel-<id>-steamcmd-<n>)
|
||||
// that mounts the same data volumes. If we don't cancel it before
|
||||
// trying to remove volumes, the sidecar holds them open and the
|
||||
// volume-rm fails with "volume is in use" — operator sees the
|
||||
// instance stuck on "Deleting…" while a download finishes in the
|
||||
// background.
|
||||
d.cancelUpdater(req.InstanceId)
|
||||
// Whatever state the main container is in, any Files-tab helper that
|
||||
// was running against this instance is now orphaned. Clean it up
|
||||
// before touching anything else so its volume-holds don't interfere
|
||||
// with the main container's stop/remove.
|
||||
d.teardownFsHelper(req.InstanceId)
|
||||
|
||||
grace := time.Duration(req.GraceSeconds) * time.Second
|
||||
if grace == 0 {
|
||||
grace = 15 * time.Second
|
||||
}
|
||||
|
||||
// If we have a record, use its container_id directly. Otherwise try
|
||||
// to find the container by its canonical name (may exist from a
|
||||
// previous session that skipped rehydrate).
|
||||
containerID := ""
|
||||
if exists {
|
||||
containerID = rec.ContainerID
|
||||
} else {
|
||||
if state, err := d.runtime.InspectByName(ctx, "panel-"+req.InstanceId); err == nil {
|
||||
containerID = state.ContainerID
|
||||
}
|
||||
}
|
||||
|
||||
if containerID != "" {
|
||||
// Best-effort stop; ignore errors since container may already be stopped.
|
||||
_ = d.runtime.Stop(ctx, containerID, grace)
|
||||
removeErr := d.runtime.Remove(ctx, containerID)
|
||||
// d.runtime.Remove already swallows not-found, so a non-nil
|
||||
// removeErr means the remove genuinely failed. Before we tear down
|
||||
// any bookkeeping (in-memory record + sidecar metadata) we must be
|
||||
// SURE the container is gone — otherwise we orphan a live container
|
||||
// the controller still routes to, and the agent forgets it on the
|
||||
// next rehydrate. The classic trigger: a delete racing with agent
|
||||
// shutdown, where the Docker connection dies mid-remove
|
||||
// ("terminated signal received"). The remove errors, and the
|
||||
// follow-up existence check ALSO errors — fate unknown, not gone.
|
||||
if removeErr != nil {
|
||||
exists, existErr := d.runtime.ContainerExists(ctx, "panel-"+req.InstanceId)
|
||||
switch {
|
||||
case existErr != nil:
|
||||
// Couldn't even ask (daemon down / connection dropped /
|
||||
// ctx cancelled). Do NOT delete the sidecar — bail and let
|
||||
// the operator retry once the daemon is reachable. Leaving
|
||||
// the sidecar means the next rehydrate re-adopts the
|
||||
// instance instead of orphaning it.
|
||||
log.Error("delete: container remove failed and existence is unknown — aborting to avoid orphan",
|
||||
"remove_err", removeErr, "exists_err", existErr)
|
||||
d.replyError(corrID, req.InstanceId, "container_remove",
|
||||
fmt.Sprintf("container panel-%s remove failed and existence unverifiable: %s", req.InstanceId, removeErr.Error()))
|
||||
return
|
||||
case exists:
|
||||
// Container is confirmed still present. The controller must
|
||||
// learn — otherwise the DB row gets purged while a zombie
|
||||
// container lives on, and the next create with the same id
|
||||
// fails on a "name already in use" docker error.
|
||||
log.Error("delete: container still exists after remove", "err", removeErr)
|
||||
d.replyError(corrID, req.InstanceId, "container_remove",
|
||||
fmt.Sprintf("container panel-%s still exists after remove: %s", req.InstanceId, removeErr.Error()))
|
||||
return
|
||||
default:
|
||||
// Confirmed gone (e.g. autoremove fired between Stop and
|
||||
// Remove). Safe to proceed with bookkeeping cleanup.
|
||||
log.Warn("delete: remove returned error but container is confirmed gone", "err", removeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal any live trackers / log streams to stop.
|
||||
if exists {
|
||||
if rec.TrackerCancel != nil {
|
||||
rec.TrackerCancel()
|
||||
}
|
||||
if rec.LogCancel != nil {
|
||||
rec.LogCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Purge volumes if requested.
|
||||
if req.PurgeVolumes {
|
||||
dr, ok := d.runtime.(*runtime.DockerRuntime)
|
||||
if !ok {
|
||||
log.Warn("delete: purge requested but runtime is not Docker; skipping volume cleanup")
|
||||
} else {
|
||||
// Sweep every container that mounts one of this instance's
|
||||
// volumes — running OR exited. Without this, a stranded
|
||||
// steamcmd / backup / fs-helper sidecar from a previous
|
||||
// agent crash keeps the volume "in use" and volume-rm
|
||||
// fails repeatedly. Operator sees the instance stuck
|
||||
// "Deleting…" forever.
|
||||
holders, err := dr.ListContainersHoldingInstanceVolumes(ctx, req.InstanceId)
|
||||
if err != nil {
|
||||
log.Warn("delete: scan for orphan sidecars failed", "err", err)
|
||||
}
|
||||
for _, hid := range holders {
|
||||
if err := dr.ForceRemoveContainer(ctx, hid); err != nil {
|
||||
log.Warn("delete: force-remove sidecar failed", "container_id", hid, "err", err)
|
||||
} else {
|
||||
log.Info("delete: removed orphan sidecar", "container_id", short(hid))
|
||||
}
|
||||
}
|
||||
vols, err := dr.ListVolumesByInstance(ctx, req.InstanceId)
|
||||
if err != nil {
|
||||
log.Warn("delete: list volumes failed", "err", err)
|
||||
}
|
||||
for _, v := range vols {
|
||||
// Force=true: there's no scenario where we want a panel
|
||||
// volume to survive a purge=true delete after we've
|
||||
// scrubbed every container that referenced it.
|
||||
if err := dr.RemoveVolume(ctx, v, true); err != nil {
|
||||
log.Warn("delete: remove volume failed", "volume", v, "err", err)
|
||||
d.replyError(corrID, req.InstanceId, "volume_remove", fmt.Sprintf("volume %s: %s", v, err.Error()))
|
||||
return
|
||||
}
|
||||
log.Info("delete: purged volume", "volume", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove in-memory record + sidecar metadata.
|
||||
d.mu.Lock()
|
||||
delete(d.instances, req.InstanceId)
|
||||
d.mu.Unlock()
|
||||
d.deleteMeta(req.InstanceId)
|
||||
|
||||
// Leave host data_path alone (see doc comment); operators can rm -rf
|
||||
// manually. We do NOT attempt os.RemoveAll here — too destructive to
|
||||
// do automatically on a shared multi-instance directory layout.
|
||||
|
||||
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "deleted")
|
||||
d.replyOK(corrID)
|
||||
log.Info("instance deleted", "purge_volumes", req.PurgeVolumes)
|
||||
_ = os.Stat // kept imported for future host-data_path removal flag
|
||||
}
|
||||
|
||||
// sendDelete-specific correlation reply uses the existing replyOK/err pattern.
|
||||
var _ = timestamppb.Now // keep import hint while ctx-based future expansions land
|
||||
Reference in New Issue
Block a user