5232609719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
505 lines
18 KiB
Go
505 lines
18 KiB
Go
package dispatch
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
agentmodule "github.com/dbledeez/panel/agent/internal/module"
|
|
"github.com/dbledeez/panel/agent/internal/runtime"
|
|
modulepkg "github.com/dbledeez/panel/pkg/module"
|
|
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
|
)
|
|
|
|
// buildBackupTarCmd assembles the tar command run inside the sidecar.
|
|
// Manifest BackupSpec wins when Include is set: the tar runs against
|
|
// the listed paths (each relative to BrowseableRoot, missing ones
|
|
// silently skipped). Otherwise it falls back to a whole-tree archive
|
|
// of BrowseableRoot. Excludes are passed through tar's --exclude.
|
|
//
|
|
// Resulting tarball preserves the directory layout under BrowseableRoot
|
|
// so restore can extract straight into the same root.
|
|
func buildBackupTarCmd(manifest *modulepkgManifest, root, fileName string, keep int) string {
|
|
if manifest != nil && manifest.Backup != nil && manifest.Backup.Root != "" {
|
|
root = manifest.Backup.Root
|
|
}
|
|
var base string
|
|
if manifest == nil || manifest.Backup == nil || len(manifest.Backup.Include) == 0 {
|
|
// Fallback: whole tree.
|
|
base = fmt.Sprintf("cd %q && tar czf /backup/%s . && stat -c %%s /backup/%s",
|
|
root, shellEscape(fileName), shellEscape(fileName))
|
|
} else {
|
|
// Per-game include list. Missing paths are non-fatal — players will
|
|
// create new instances on a fresh server before the save dir exists,
|
|
// and we don't want their first Back-up-now to fail the run.
|
|
excludes := ""
|
|
for _, e := range manifest.Backup.Exclude {
|
|
excludes += " --exclude=" + shellEscape(e)
|
|
}
|
|
// Build "test -e <path> && echo <path>" for each include — only
|
|
// existing paths get added to the tar argv, so the operator doesn't
|
|
// see a confusing "Cannot stat: No such file" on first run.
|
|
//
|
|
// Newline-delimited (NOT NUL-delimited) because the sidecar image is
|
|
// alpine, which ships busybox tar — and busybox tar doesn't grok
|
|
// `--null`. Manifest paths never contain whitespace or newlines (we
|
|
// hand-curate them per game), so a plain newline list is safe.
|
|
var existsChecks []string
|
|
for _, p := range manifest.Backup.Include {
|
|
clean := strings.TrimPrefix(p, "./")
|
|
clean = strings.TrimPrefix(clean, "/")
|
|
existsChecks = append(existsChecks, fmt.Sprintf("[ -e %s ] && printf '%%s\\n' %s", shellEscape(clean), shellEscape(clean)))
|
|
}
|
|
listExpr := "{ " + strings.Join(existsChecks, " ; ") + " ; }"
|
|
base = fmt.Sprintf("cd %q && %s | tar czf /backup/%s%s -T - && stat -c %%s /backup/%s",
|
|
root, listExpr, shellEscape(fileName), excludes, shellEscape(fileName))
|
|
}
|
|
return base + backupPruneSuffix(keep)
|
|
}
|
|
|
|
// backupPruneSuffix appends a best-effort retention prune that keeps only the
|
|
// newest `keep` *.tar.gz in /backup (by mtime) and deletes the rest. It runs in
|
|
// the SAME root sidecar that just wrote the new tarball — the backup files are
|
|
// root-owned, so the unprivileged agent can't prune them itself. keep<=0 leaves
|
|
// pruning off. busybox-safe (alpine): ls -1t + tail -n +N + while-read — no
|
|
// `head -n -N` (GNU-only) and no `xargs -r`.
|
|
func backupPruneSuffix(keep int) string {
|
|
if keep <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(" ; echo 'prune: keeping newest %d backups' ; ls -1t /backup/*.tar.gz 2>/dev/null | tail -n +%d | while IFS= read -r f; do echo \"prune: rm $f\" ; rm -f \"$f\" ; done",
|
|
keep, keep+1)
|
|
}
|
|
|
|
// readBackupKeep returns the backup retention for this instance: the integer in
|
|
// <dataPath>/backup-keep when present, else the PANEL_BACKUP_KEEP env default,
|
|
// else 10. A rolling cap stops the unbounded pileup that filled 90 GB on a busy
|
|
// 7DTD season. Set per-instance by writing the file (the Backups UI exposes it).
|
|
func readBackupKeep(dataPath string) int {
|
|
def := 10
|
|
if v := os.Getenv("PANEL_BACKUP_KEEP"); v != "" {
|
|
if n, e := strconv.Atoi(strings.TrimSpace(v)); e == nil && n > 0 {
|
|
def = n
|
|
}
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(dataPath, "backup-keep"))
|
|
if err != nil {
|
|
return def
|
|
}
|
|
if n, e := strconv.Atoi(strings.TrimSpace(string(b))); e == nil && n > 0 {
|
|
return n
|
|
}
|
|
return def
|
|
}
|
|
|
|
// modulepkgManifest is a local alias so the helper signature below
|
|
// doesn't drag modulepkg into its return type at the file top.
|
|
type modulepkgManifest = modulepkg.Manifest
|
|
|
|
// Backup / restore via sidecar containers. Two operations:
|
|
//
|
|
// - Backup: tar -czf /backup/<file>.tar.gz -C /src . (volume readonly at /src)
|
|
// - Restore: tar -xzf /backup/<file>.tar.gz -C /dst (volume read-write at /dst)
|
|
//
|
|
// The sidecar uses alpine:3.21 + busybox tar. Backup files live on the
|
|
// agent host under $BACKUP_DIR/<instance_id>/<ts>-<bkpID>.tar.gz; both
|
|
// sidecars bind-mount $BACKUP_DIR/<instance_id> at /backup.
|
|
//
|
|
// For restore we refuse if the main container is running: extracting
|
|
// files under a live server would corrupt world state. Operator stops
|
|
// first. (Symmetrical to the SteamCMD updater's running-container check.)
|
|
|
|
const (
|
|
backupImage = "alpine:3.21"
|
|
backupSidecarTTL = 30 * time.Minute
|
|
)
|
|
|
|
// ---- Backup ----
|
|
|
|
func (d *Dispatcher) handleBackup(ctx context.Context, corrID string, req *panelv1.BackupRequest) {
|
|
log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)
|
|
|
|
rec, err := d.lookupRecord(req.InstanceId)
|
|
if err != nil {
|
|
d.sendBackupResult(corrID, "", "", 0, err.Error())
|
|
return
|
|
}
|
|
if rec.BrowseableRoot == "" {
|
|
d.sendBackupResult(corrID, "", "", 0, "instance has no BrowseableRoot to tar (module declares no volumes)")
|
|
return
|
|
}
|
|
if d.backupDir == "" {
|
|
d.sendBackupResult(corrID, "", "", 0, "agent has no --backup-dir configured")
|
|
return
|
|
}
|
|
|
|
// Prepare the agent-side target directory.
|
|
bkpID := "bkp_" + randHex(8)
|
|
hostDir := filepath.Join(d.backupDir, req.InstanceId)
|
|
if err := os.MkdirAll(hostDir, 0o755); err != nil {
|
|
d.sendBackupResult(corrID, "", "", 0, fmt.Sprintf("mkdir %s: %s", hostDir, err.Error()))
|
|
return
|
|
}
|
|
ts := time.Now().UTC().Format("20060102-150405")
|
|
fileName := fmt.Sprintf("%s-%s.tar.gz", ts, bkpID)
|
|
hostFile := filepath.Join(hostDir, fileName)
|
|
|
|
log.Info("backup starting", "backup_id", bkpID, "host_file", hostFile)
|
|
|
|
// Build sidecar spec that mounts the same volumes as the instance,
|
|
// plus the host backup directory at /backup.
|
|
volumes := agentmodule.ResolveVolumes(d.modulesMustGet(rec.ModuleID), req.InstanceId, rec.DataPath)
|
|
// Mark volumes read-only for backup safety.
|
|
for i := range volumes {
|
|
volumes[i].ReadOnly = true
|
|
}
|
|
// Add the host backup dir as a (read-write) bind.
|
|
volumes = append(volumes, runtime.VolumeSpec{
|
|
Type: "bind",
|
|
HostPath: hostDir,
|
|
ContainerPath: "/backup",
|
|
})
|
|
|
|
// Per-game backup paths. If the manifest declares Backup.Include,
|
|
// only those paths get archived (saves-only — keeps ARK SA tarballs
|
|
// at ~250 MB instead of the 18 GB whole-install). Without a spec,
|
|
// fall back to the original "tar everything under BrowseableRoot"
|
|
// behavior so older modules don't regress.
|
|
manifest := d.modulesMustGet(rec.ModuleID)
|
|
keep := readBackupKeep(rec.DataPath)
|
|
tarCmd := buildBackupTarCmd(manifest, rec.BrowseableRoot, fileName, keep)
|
|
if manifest != nil && manifest.Backup != nil && len(manifest.Backup.Include) > 0 {
|
|
log.Info("backup using manifest-declared paths", "include_count", len(manifest.Backup.Include))
|
|
} else {
|
|
log.Warn("backup falling back to whole BrowseableRoot — module manifest has no `backup.include`",
|
|
"module_id", rec.ModuleID, "root", rec.BrowseableRoot)
|
|
}
|
|
spec := runtime.InstanceSpec{
|
|
InstanceID: req.InstanceId + "-backup",
|
|
IsSidecar: true,
|
|
Image: backupImage,
|
|
Entrypoint: []string{"sh"},
|
|
Command: []string{"-c", tarCmd},
|
|
Volumes: volumes,
|
|
RestartPolicy: "no",
|
|
}
|
|
|
|
contID, err := d.runtime.Create(ctx, spec)
|
|
if err != nil {
|
|
d.sendBackupResult(corrID, bkpID, "", 0, "create sidecar: "+err.Error())
|
|
return
|
|
}
|
|
defer func() {
|
|
rmCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = d.runtime.Remove(rmCtx, contID)
|
|
}()
|
|
|
|
if err := d.runtime.Start(ctx, contID); err != nil {
|
|
d.sendBackupResult(corrID, bkpID, "", 0, "start sidecar: "+err.Error())
|
|
return
|
|
}
|
|
// Stream log lines for visibility.
|
|
logCtx, cancelLogs := context.WithCancel(ctx)
|
|
logDone := make(chan struct{})
|
|
go func() {
|
|
defer close(logDone)
|
|
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
|
InstanceId: rec.InstanceID, Stream: "backup",
|
|
At: timestamppb.Now(), Line: line,
|
|
}},
|
|
})
|
|
})
|
|
}()
|
|
exitCode, err := d.runtime.Wait(ctx, contID)
|
|
cancelLogs()
|
|
<-logDone
|
|
if err != nil {
|
|
d.sendBackupResult(corrID, bkpID, "", 0, "wait: "+err.Error())
|
|
return
|
|
}
|
|
if exitCode != 0 {
|
|
d.sendBackupResult(corrID, bkpID, "", 0, fmt.Sprintf("sidecar exited %d", exitCode))
|
|
return
|
|
}
|
|
|
|
info, err := os.Stat(hostFile)
|
|
if err != nil {
|
|
d.sendBackupResult(corrID, bkpID, hostFile, 0, "stat result: "+err.Error())
|
|
return
|
|
}
|
|
log.Info("backup complete", "backup_id", bkpID, "size_bytes", info.Size())
|
|
d.sendBackupResult(corrID, bkpID, hostFile, info.Size(), "")
|
|
}
|
|
|
|
func (d *Dispatcher) sendBackupResult(corrID, bkpID, path string, size int64, errMsg string) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
CorrelationId: corrID,
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_BackupResult{
|
|
BackupResult: &panelv1.BackupResult{
|
|
BackupId: bkpID,
|
|
Path: path,
|
|
SizeBytes: size,
|
|
Error: errMsg,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---- Wipe paths (used by the env-config recreate flow) ----
|
|
//
|
|
// Spawns an alpine sidecar that mounts the same volumes the instance
|
|
// uses, runs `rm -rf` on each path (relative to BrowseableRoot), then
|
|
// exits. Designed for the ARK map-switch flow — operator picked a new
|
|
// SERVER_MAP and checked "wipe existing world data" so the old map's
|
|
// SavedArks/<MapName>/ goes away. Generic enough that any future
|
|
// env-config field can declare its own wipe paths.
|
|
func (d *Dispatcher) runWipeSidecar(ctx context.Context, manifest *modulepkg.Manifest, instanceID, dataPath string, paths []string) error {
|
|
if manifest == nil {
|
|
return errors.New("nil manifest")
|
|
}
|
|
root := resolveBrowseableRoot(manifest)
|
|
if root == "" {
|
|
return errors.New("module declares no browseable_root — wipe needs a base path")
|
|
}
|
|
volumes := agentmodule.ResolveVolumes(manifest, instanceID, dataPath)
|
|
// Build the rm -rf command. Each path is rooted under
|
|
// BrowseableRoot. Missing paths are silently skipped (first run).
|
|
var rmParts []string
|
|
for _, p := range paths {
|
|
clean := p
|
|
// Defensive: reject absolute paths and parent traversal so a
|
|
// rogue config_values value can't `rm -rf /` the container.
|
|
if strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
|
return fmt.Errorf("refusing wipe of suspicious path %q", p)
|
|
}
|
|
full := root + "/" + clean
|
|
rmParts = append(rmParts, fmt.Sprintf("rm -rf %s", shellEscape(full)))
|
|
}
|
|
cmd := strings.Join(rmParts, " && ") + " && echo wiped"
|
|
|
|
spec := runtime.InstanceSpec{
|
|
InstanceID: instanceID + "-wipe",
|
|
IsSidecar: true,
|
|
Image: backupImage,
|
|
Entrypoint: []string{"sh"},
|
|
Command: []string{"-c", cmd},
|
|
Volumes: volumes,
|
|
RestartPolicy: "no",
|
|
}
|
|
contID, err := d.runtime.Create(ctx, spec)
|
|
if err != nil {
|
|
return fmt.Errorf("create wipe sidecar: %w", err)
|
|
}
|
|
defer func() {
|
|
rmCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = d.runtime.Remove(rmCtx, contID)
|
|
}()
|
|
if err := d.runtime.Start(ctx, contID); err != nil {
|
|
return fmt.Errorf("start wipe sidecar: %w", err)
|
|
}
|
|
logCtx, cancelLogs := context.WithCancel(ctx)
|
|
logDone := make(chan struct{})
|
|
go func() {
|
|
defer close(logDone)
|
|
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
|
InstanceId: instanceID, Stream: "wipe",
|
|
At: timestamppb.Now(), Line: line,
|
|
}},
|
|
})
|
|
})
|
|
}()
|
|
exitCode, err := d.runtime.Wait(ctx, contID)
|
|
cancelLogs()
|
|
<-logDone
|
|
if err != nil {
|
|
return fmt.Errorf("wait wipe sidecar: %w", err)
|
|
}
|
|
if exitCode != 0 {
|
|
return fmt.Errorf("wipe sidecar exited %d", exitCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- Restore ----
|
|
|
|
func (d *Dispatcher) handleRestore(ctx context.Context, corrID string, req *panelv1.RestoreRequest) {
|
|
log := d.log.With("instance_id", req.InstanceId, "backup_id", req.BackupId, "correlation_id", corrID)
|
|
|
|
rec, err := d.lookupRecord(req.InstanceId)
|
|
if err != nil {
|
|
d.sendRestoreResult(corrID, 0, err.Error())
|
|
return
|
|
}
|
|
if rec.BrowseableRoot == "" {
|
|
d.sendRestoreResult(corrID, 0, "instance has no BrowseableRoot to restore into")
|
|
return
|
|
}
|
|
if req.BackupPath == "" {
|
|
d.sendRestoreResult(corrID, 0, "backup_path required")
|
|
return
|
|
}
|
|
// Refuse if container is running.
|
|
if state, err := d.runtime.InspectByName(ctx, "panel-"+req.InstanceId); err == nil && state.Status == "running" {
|
|
d.sendRestoreResult(corrID, 0, "stop the instance first — main container is running")
|
|
return
|
|
}
|
|
info, err := os.Stat(req.BackupPath)
|
|
if err != nil || info.IsDir() {
|
|
d.sendRestoreResult(corrID, 0, "backup file missing or is a directory")
|
|
return
|
|
}
|
|
|
|
volumes := agentmodule.ResolveVolumes(d.modulesMustGet(rec.ModuleID), req.InstanceId, rec.DataPath)
|
|
// Add the backup file's parent as a read-only bind at /backup.
|
|
hostDir := filepath.Dir(req.BackupPath)
|
|
fileName := filepath.Base(req.BackupPath)
|
|
volumes = append(volumes, runtime.VolumeSpec{
|
|
Type: "bind",
|
|
HostPath: hostDir,
|
|
ContainerPath: "/backup",
|
|
ReadOnly: true,
|
|
})
|
|
|
|
// Extract strategy depends on whether the backup is per-game-paths
|
|
// or a whole-tree archive. When the manifest declares Backup.Include,
|
|
// only THOSE paths are wiped before extraction — preserves the
|
|
// 18 GB game install while still giving a clean save dir. Without a
|
|
// spec, fall back to wiping all of BrowseableRoot (legacy whole-tree
|
|
// behavior).
|
|
manifest := d.modulesMustGet(rec.ModuleID)
|
|
// Resolve the SAME root the backup wrote against. buildBackupTarCmd
|
|
// archives relative to manifest.Backup.Root when set (e.g. 7DTD's
|
|
// /game-saves, where world data actually lives — distinct from the
|
|
// /game install volume that is BrowseableRoot). Restore MUST extract
|
|
// into that same root or the tarball lands in the wrong volume and the
|
|
// live server never sees it (silent data loss: restore "succeeds" but
|
|
// players' bases are gone). Mirror the backup's resolution exactly.
|
|
restoreRoot := rec.BrowseableRoot
|
|
if manifest != nil && manifest.Backup != nil && manifest.Backup.Root != "" {
|
|
restoreRoot = manifest.Backup.Root
|
|
}
|
|
var wipeCmd string
|
|
if manifest != nil && manifest.Backup != nil && len(manifest.Backup.Include) > 0 {
|
|
// Wipe only the manifest-declared include paths so we don't
|
|
// nuke unrelated install files. Each path is rm -rf'd; missing
|
|
// ones are skipped silently (first-run case).
|
|
var parts []string
|
|
for _, p := range manifest.Backup.Include {
|
|
clean := strings.TrimPrefix(strings.TrimPrefix(p, "./"), "/")
|
|
parts = append(parts, fmt.Sprintf("rm -rf %s/%s", shellEscape(restoreRoot), shellEscape(clean)))
|
|
}
|
|
wipeCmd = strings.Join(parts, " ; ") + " 2>/dev/null"
|
|
log.Info("restore: wiping manifest-declared paths only", "root", restoreRoot, "include_count", len(manifest.Backup.Include))
|
|
} else {
|
|
wipeCmd = fmt.Sprintf("rm -rf %q/* %q/.[!.]* 2>/dev/null", restoreRoot, restoreRoot)
|
|
log.Warn("restore: wiping entire root (whole-tree backup)", "root", restoreRoot)
|
|
}
|
|
extractCmd := fmt.Sprintf(
|
|
"%s ; tar xzf /backup/%s -C %q && du -sb %q | cut -f1",
|
|
wipeCmd,
|
|
shellEscape(fileName),
|
|
restoreRoot,
|
|
restoreRoot,
|
|
)
|
|
spec := runtime.InstanceSpec{
|
|
InstanceID: req.InstanceId + "-restore",
|
|
IsSidecar: true,
|
|
Image: backupImage,
|
|
Entrypoint: []string{"sh"},
|
|
Command: []string{"-c", extractCmd},
|
|
Volumes: volumes,
|
|
RestartPolicy: "no",
|
|
}
|
|
contID, err := d.runtime.Create(ctx, spec)
|
|
if err != nil {
|
|
d.sendRestoreResult(corrID, 0, "create sidecar: "+err.Error())
|
|
return
|
|
}
|
|
defer func() {
|
|
rmCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = d.runtime.Remove(rmCtx, contID)
|
|
}()
|
|
if err := d.runtime.Start(ctx, contID); err != nil {
|
|
d.sendRestoreResult(corrID, 0, "start sidecar: "+err.Error())
|
|
return
|
|
}
|
|
logCtx, cancelLogs := context.WithCancel(ctx)
|
|
logDone := make(chan struct{})
|
|
go func() {
|
|
defer close(logDone)
|
|
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
|
InstanceId: rec.InstanceID, Stream: "restore",
|
|
At: timestamppb.Now(), Line: line,
|
|
}},
|
|
})
|
|
})
|
|
}()
|
|
exitCode, err := d.runtime.Wait(ctx, contID)
|
|
cancelLogs()
|
|
<-logDone
|
|
if err != nil {
|
|
d.sendRestoreResult(corrID, 0, "wait: "+err.Error())
|
|
return
|
|
}
|
|
if exitCode != 0 {
|
|
d.sendRestoreResult(corrID, 0, fmt.Sprintf("sidecar exited %d", exitCode))
|
|
return
|
|
}
|
|
log.Info("restore complete")
|
|
d.sendRestoreResult(corrID, info.Size(), "")
|
|
}
|
|
|
|
func (d *Dispatcher) sendRestoreResult(corrID string, bytesRestored int64, errMsg string) {
|
|
d.sendEnv(&panelv1.AgentEnvelope{
|
|
CorrelationId: corrID,
|
|
SentAt: timestamppb.Now(),
|
|
Payload: &panelv1.AgentEnvelope_RestoreResult{
|
|
RestoreResult: &panelv1.RestoreResult{BytesRestored: bytesRestored, Error: errMsg},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func randHex(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// shellEscape wraps a filename in single quotes for safe inclusion in an
|
|
// sh -c command. Assumes filename never contains a single-quote (ours
|
|
// never do — timestamps + bkp_<hex>).
|
|
func shellEscape(s string) string { return "'" + s + "'" }
|
|
|
|
// modulesMustGet returns the dispatcher's manifest for the given id, or
|
|
// nil if it's gone missing (shouldn't happen for a live instance).
|
|
func (d *Dispatcher) modulesMustGet(id string) *modulepkgManifest {
|
|
m, _ := d.modules.Get(id)
|
|
return m
|
|
}
|
|
|
|
// Keep errors imported for future error-wrapping.
|
|
var _ = errors.New |