package calendar
import (
"fmt"
"io"
"net/http"
"strings"
"time"
ics "github.com/arran4/golang-ical"
"github.com/teambition/rrule-go"
)
// EventType represents the type of server event
type EventType string
const (
EventTypeRestart EventType = "restart"
EventTypeWipe EventType = "wipe"
)
// Event represents a parsed calendar event
type Event struct {
Type EventType
StartTime time.Time
EndTime time.Time
Summary string
}
// ScheduledEvent represents an event ready for execution
type ScheduledEvent struct {
Type EventType
StartTime time.Time
}
// FetchCalendar downloads an .ics file from a URL
func FetchCalendar(url string) (*ics.Calendar, error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch calendar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
cal, err := ics.ParseCalendar(strings.NewReader(string(data)))
if err != nil {
return nil, fmt.Errorf("failed to parse calendar: %w", err)
}
return cal, nil
}
// GetUpcomingEvents extracts restart and wipe events within the lookahead window
func GetUpcomingEvents(cal *ics.Calendar, lookaheadHours int) ([]Event, error) {
now := time.Now()
windowEnd := now.Add(time.Duration(lookaheadHours) * time.Hour)
var events []Event
for _, component := range cal.Components {
if event, ok := component.(*ics.VEvent); ok {
summaryProp := event.GetProperty(ics.ComponentPropertySummary)
if summaryProp == nil {
continue
}
summary := strings.ToLower(strings.TrimSpace(summaryProp.Value))
// Only process "restart" or "wipe" events
var eventType EventType
if summary == "restart" {
eventType = EventTypeRestart
} else if summary == "wipe" {
eventType = EventTypeWipe
} else {
continue
}
// Get start time
dtstart := event.GetProperty(ics.ComponentPropertyDtStart)
if dtstart == nil {
continue
}
startTime, err := parseTimeWithTimezone(dtstart, cal)
if err != nil {
continue
}
// Get end time
var endTime time.Time
dtend := event.GetProperty(ics.ComponentPropertyDtEnd)
if dtend != nil {
endTime, _ = parseTimeWithTimezone(dtend, cal)
} else {
endTime = startTime.Add(1 * time.Hour) // Default 1 hour duration
}
// Check for recurring rule (use string literal since constant may not exist)
rruleProp := event.GetProperty("RRULE")
if rruleProp != nil {
// Handle recurring events
recurringEvents, err := expandRecurringEvent(startTime, endTime, rruleProp.Value, now, windowEnd, eventType, summary)
if err == nil {
events = append(events, recurringEvents...)
}
} else {
// Single event
if startTime.After(now) && startTime.Before(windowEnd) {
events = append(events, Event{
Type: eventType,
StartTime: startTime,
EndTime: endTime,
Summary: summary,
})
}
}
}
}
return events, nil
}
// expandRecurringEvent expands a recurring event within the time window
func expandRecurringEvent(startTime, endTime time.Time, rruleStr string, windowStart, windowEnd time.Time, eventType EventType, summary string) ([]Event, error) {
// Parse RRULE
r, err := rrule.StrToRRule(rruleStr)
if err != nil {
return nil, fmt.Errorf("failed to parse RRULE: %w", err)
}
// Set the DTSTART for the rule
r.DTStart(startTime)
// Get occurrences within the window (extended slightly for safety)
occurrences := r.Between(windowStart.Add(-24*time.Hour), windowEnd.Add(24*time.Hour), true)
var events []Event
duration := endTime.Sub(startTime)
for _, occurrence := range occurrences {
// Only include events within our actual window
if occurrence.After(windowStart) && occurrence.Before(windowEnd) {
events = append(events, Event{
Type: eventType,
StartTime: occurrence,
EndTime: occurrence.Add(duration),
Summary: summary,
})
}
}
return events, nil
}
// parseTimeWithTimezone parses time from iCalendar property, respecting TZID parameter
func parseTimeWithTimezone(prop *ics.IANAProperty, cal *ics.Calendar) (time.Time, error) {
if prop == nil {
return time.Time{}, fmt.Errorf("nil property")
}
timeStr := prop.Value
// Check if there's a TZID parameter
tzid := ""
if prop.ICalParameters != nil {
if tzidParam, ok := prop.ICalParameters["TZID"]; ok && len(tzidParam) > 0 {
tzid = tzidParam[0]
}
}
// If we have a TZID, try to load that timezone
var loc *time.Location
if tzid != "" {
// Try to load the timezone by IANA name
if l, err := time.LoadLocation(tzid); err == nil {
loc = l
} else {
// Fallback to UTC if we can't load the timezone
loc = time.UTC
}
}
// Common iCalendar time formats
formats := []string{
"20060102T150405Z", // UTC format (Z suffix means UTC)
"20060102T150405", // Local/TZID format
"2006-01-02T15:04:05Z", // ISO 8601 UTC
"2006-01-02T15:04:05", // ISO 8601 local
}
for _, format := range formats {
var t time.Time
var err error
// If time string ends with Z, it's UTC regardless of TZID
if strings.HasSuffix(timeStr, "Z") {
t, err = time.Parse(format, timeStr)
} else if loc != nil {
// Parse in the specified timezone
t, err = time.ParseInLocation(format, timeStr, loc)
} else {
// Parse as UTC if no timezone specified
t, err = time.Parse(format, timeStr)
}
if err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unable to parse time: %s (tzid: %s)", timeStr, tzid)
}
package carbon
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/maintc/wipe-cli/internal/discord"
)
const (
CarbonAPIURL = "https://api.carbonmod.gg/meta/carbon/changelogs.json"
CarbonBase = "/opt/carbon"
CarbonMainURL = "https://github.com/CarbonCommunity/Carbon/releases/download/production_build/Carbon.Linux.Release.tar.gz"
CarbonStagingURL = "https://github.com/CarbonCommunity/Carbon/releases/download/rustbeta_staging_build/Carbon.Linux.Debug.tar.gz"
RustEditURL = "https://github.com/k1lly0u/Oxide.Ext.RustEdit/raw/master/Oxide.Ext.RustEdit.dll"
)
var (
// installingMutex prevents concurrent Carbon installations
installingMutex sync.Mutex
installingBranches = make(map[string]bool)
// branchLocks provides per-branch RW locks to coordinate installs vs syncs
branchLocks = make(map[string]*sync.RWMutex)
branchMutex sync.Mutex
)
// CarbonRelease represents a Carbon release from the API
type CarbonRelease struct {
Date string `json:"Date"`
Version string `json:"Version"`
CommitURL string `json:"CommitUrl"`
}
// getBranchLock gets or creates an RWMutex for a specific branch
func getBranchLock(branch string) *sync.RWMutex {
branchMutex.Lock()
defer branchMutex.Unlock()
if lock, exists := branchLocks[branch]; exists {
return lock
}
lock := &sync.RWMutex{}
branchLocks[branch] = lock
return lock
}
// AcquireReadLock acquires a read lock for a branch (used by syncServer)
// Returns an unlock function that must be called when done reading
func AcquireReadLock(branch string) func() {
if branch == "" || branch == "main" {
branch = "main"
}
lock := getBranchLock(branch)
lock.RLock()
log.Printf("Acquired Carbon read lock for branch '%s'", branch)
return func() {
lock.RUnlock()
log.Printf("Released Carbon read lock for branch '%s'", branch)
}
}
// getCarbonPath returns the installation path for a branch
func getCarbonPath(branch string) string {
if branch == "" || branch == "main" {
return filepath.Join(CarbonBase, "main")
}
return filepath.Join(CarbonBase, branch)
}
// isCarbonInstalled checks if Carbon is installed
func isCarbonInstalled(path string) bool {
carbonDLL := filepath.Join(path, "carbon", "managed", "Carbon.dll")
_, err := os.Stat(carbonDLL)
return err == nil
}
// CheckForCarbonUpdates checks if Carbon has updates available
func CheckForCarbonUpdates(branch, webhookURL string) (bool, string, error) {
installPath := getCarbonPath(branch)
// Check if Carbon is installed
if !isCarbonInstalled(installPath) {
return false, "", nil
}
// Get current installed version
versionPath := filepath.Join(installPath, "version.txt")
currentVersionData, err := os.ReadFile(versionPath)
if err != nil {
log.Printf("Warning: Could not read current Carbon version for %s: %v", branch, err)
return false, "", nil
}
currentVersion := strings.TrimSpace(string(currentVersionData))
// Get latest version from Carbon API
latestVersion, err := getLatestCarbonVersion()
if err != nil {
log.Printf("Error checking for Carbon updates: %v", err)
return false, "", err
}
// Compare versions
if currentVersion != latestVersion {
log.Printf("Carbon update available for branch %s: %s -> %s", branch, currentVersion, latestVersion)
// Send notification
discord.SendInfo(webhookURL, "Carbon Update Available",
fmt.Sprintf("Carbon has an update available\n\nCurrent: **%s**\nAvailable: **%s**",
currentVersion, latestVersion))
return true, latestVersion, nil
}
return false, currentVersion, nil
}
// getLatestCarbonVersion queries the Carbon API for the latest version
func getLatestCarbonVersion() (string, error) {
resp, err := http.Get(CarbonAPIURL)
if err != nil {
return "", fmt.Errorf("failed to fetch Carbon API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("carbon API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Carbon API response: %w", err)
}
var releases []CarbonRelease
if err := json.Unmarshal(body, &releases); err != nil {
return "", fmt.Errorf("failed to parse Carbon API response: %w", err)
}
if len(releases) == 0 {
return "", fmt.Errorf("no Carbon releases found")
}
// First entry is the latest
return releases[0].Version, nil
}
// GetCarbonDownloadURL returns the download URL for a Carbon branch
func GetCarbonDownloadURL(branch string) string {
if branch == "" || branch == "main" {
return CarbonMainURL
}
if branch == "staging" {
return CarbonStagingURL
}
// Default to main for unknown branches
log.Printf("Warning: Unknown Carbon branch '%s', defaulting to main", branch)
return CarbonMainURL
}
// InstallCarbon installs Carbon for a specific branch
func InstallCarbon(branch, webhookURL string) error {
// Check if this branch is already being installed
installingMutex.Lock()
if installingBranches[branch] {
installingMutex.Unlock()
log.Printf("Carbon for branch '%s' is already being installed, skipping", branch)
return nil
}
installingBranches[branch] = true
installingMutex.Unlock()
// Ensure we mark installation as complete when done
defer func() {
installingMutex.Lock()
delete(installingBranches, branch)
installingMutex.Unlock()
}()
// Normalize branch for lock acquisition
lockBranch := branch
if lockBranch == "" {
lockBranch = "main"
}
// Acquire WRITE lock for this branch to block syncServer reads during install
branchLock := getBranchLock(lockBranch)
branchLock.Lock()
defer branchLock.Unlock()
installPath := getCarbonPath(branch)
downloadURL := GetCarbonDownloadURL(branch)
log.Printf("Installing Carbon for branch '%s' to %s", branch, installPath)
// Create Carbon directory
if err := os.MkdirAll(installPath, 0755); err != nil {
errMsg := fmt.Sprintf("failed to create Carbon directory: %v", err)
discord.SendError(webhookURL, "Carbon Installation Failed",
fmt.Sprintf("Failed to install Carbon for branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Download Carbon
tarPath := filepath.Join(installPath, "carbon.tar.gz")
log.Printf("Downloading Carbon from %s...", downloadURL)
if err := downloadFile(downloadURL, tarPath); err != nil {
errMsg := fmt.Sprintf("failed to download Carbon: %v", err)
discord.SendError(webhookURL, "Carbon Installation Failed",
fmt.Sprintf("Failed to install Carbon for branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Extract Carbon
log.Printf("Extracting Carbon...")
if err := extractTarGz(tarPath, installPath); err != nil {
errMsg := fmt.Sprintf("failed to extract Carbon: %v", err)
discord.SendError(webhookURL, "Carbon Installation Failed",
fmt.Sprintf("Failed to install Carbon for branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Download RustEdit extension
log.Printf("Downloading RustEdit extension...")
rustEditPath := filepath.Join(installPath, "carbon", "extensions", "Oxide.Ext.RustEdit.dll")
if err := os.MkdirAll(filepath.Dir(rustEditPath), 0755); err == nil {
if err := downloadFile(RustEditURL, rustEditPath); err != nil {
log.Printf("Warning: Failed to download RustEdit extension: %v", err)
// Not critical, continue
}
}
// Get latest version from API and save it
version, err := getLatestCarbonVersion()
if err != nil {
log.Printf("Warning: Could not get Carbon version: %v", err)
version = "unknown"
}
versionPath := filepath.Join(installPath, "version.txt")
if err := os.WriteFile(versionPath, []byte(version), 0644); err != nil {
log.Printf("Warning: Could not write version file: %v", err)
}
// Clean up tar file
os.Remove(tarPath)
log.Printf("✓ Successfully installed Carbon for branch '%s' (version: %s)", branch, version)
discord.SendSuccess(webhookURL, "Carbon Installation Complete",
fmt.Sprintf("Carbon for branch **%s** installed successfully\n\nVersion: **%s**", branch, version))
return nil
}
// EnsureCarbonInstalled checks if Carbon is installed and installs it if not
func EnsureCarbonInstalled(branch, webhookURL string) error {
installPath := getCarbonPath(branch)
// Check if Carbon is already installed
if isCarbonInstalled(installPath) {
log.Printf("Carbon for branch '%s' already installed at %s", branch, installPath)
return nil
}
log.Printf("Carbon for branch '%s' not found at %s, installing...", branch, installPath)
return InstallCarbon(branch, webhookURL)
}
// downloadFile downloads a file from a URL
func downloadFile(url, filepath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// extractTarGz extracts a tar.gz file to a destination
func extractTarGz(tarPath, destPath string) error {
// Use tar command to extract
cmd := exec.Command("tar", "-xzf", tarPath, "-C", destPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tar extraction failed: %w\nOutput: %s", err, output)
}
return nil
}
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/viper"
)
const (
ConfigDir = ".config/wiped"
ConfigFile = "config.yaml"
)
var (
// CustomConfigPath allows overriding the default config path
// Useful for testing or alternative deployments
CustomConfigPath string
)
// Server represents a Rust server to monitor
type Server struct {
Name string `mapstructure:"name" yaml:"name"`
Path string `mapstructure:"path" yaml:"path"`
CalendarURL string `mapstructure:"calendar_url" yaml:"calendar_url"`
Branch string `mapstructure:"branch" yaml:"branch"` // Rust server branch (default: main)
WipeBlueprints bool `mapstructure:"wipe_blueprints" yaml:"wipe_blueprints"` // Whether to delete blueprints on wipe (default: false)
GenerateMap bool `mapstructure:"generate_map" yaml:"generate_map"` // Whether to generate maps via generate-maps.sh (default: false)
}
// Config holds the application configuration
type Config struct {
// How far ahead to look for events (in hours)
LookaheadHours int `mapstructure:"lookahead_hours"`
// How often to check calendars (in seconds)
CheckInterval int `mapstructure:"check_interval"`
// How long to wait after event time before executing (in seconds)
EventDelay int `mapstructure:"event_delay"`
// Discord webhook URL for notifications
DiscordWebhook string `mapstructure:"discord_webhook"`
// Discord user IDs to mention in notifications
DiscordMentionUsers []string `mapstructure:"discord_mention_users"`
// Discord role IDs to mention in notifications
DiscordMentionRoles []string `mapstructure:"discord_mention_roles"`
// How many hours before a wipe to generate the map (default: 24)
MapGenerationHours int `mapstructure:"map_generation_hours"`
// Servers to monitor
Servers []Server `mapstructure:"servers"`
}
// InitConfig initializes the configuration system
func InitConfig() {
var configPath string
// Use custom config path if set, otherwise use default
if CustomConfigPath != "" {
// Custom path provided - use it directly
viper.SetConfigFile(CustomConfigPath)
configPath = filepath.Dir(CustomConfigPath)
} else {
// Default path
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
return
}
configPath = filepath.Join(home, ConfigDir)
viper.AddConfigPath(configPath)
viper.SetConfigName("config")
viper.SetConfigType("yaml")
}
// Set defaults
viper.SetDefault("lookahead_hours", 24)
viper.SetDefault("check_interval", 30)
viper.SetDefault("event_delay", 5)
viper.SetDefault("discord_webhook", "")
viper.SetDefault("discord_mention_users", []string{})
viper.SetDefault("discord_mention_roles", []string{})
viper.SetDefault("map_generation_hours", 22)
viper.SetDefault("servers", []Server{})
// Create config directory if it doesn't exist
if err := os.MkdirAll(configPath, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
}
// Read config file if it exists
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Config file not found; create it with defaults
if err := viper.SafeWriteConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
}
}
}
}
// GetConfig returns the current configuration
func GetConfig() (*Config, error) {
// Reload config from disk to pick up external changes
if err := viper.ReadInConfig(); err != nil {
// If file doesn't exist, that's okay - we'll use defaults
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config: %w", err)
}
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &cfg, nil
}
// SaveConfig persists the configuration to disk
func SaveConfig() error {
return viper.WriteConfig()
}
// AddServer adds a new server to the configuration
func AddServer(name, path, calendarURL, branch string, wipeBlueprints, generateMap bool) error {
cfg, err := GetConfig()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
// Check if server path already exists
for _, s := range cfg.Servers {
if s.Path == path {
return fmt.Errorf("server with path %s already exists", path)
}
}
// Default to main branch if not specified
if branch == "" {
branch = "main"
}
// Add new server
cfg.Servers = append(cfg.Servers, Server{
Name: name,
Path: path,
CalendarURL: calendarURL,
Branch: branch,
WipeBlueprints: wipeBlueprints,
GenerateMap: generateMap,
})
// Update viper
viper.Set("servers", cfg.Servers)
return SaveConfig()
}
// RemoveServer removes a server from the configuration by path
func RemoveServer(identifier string) error {
cfg, err := GetConfig()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
// Find and remove server (match by name or path)
found := false
newServers := make([]Server, 0)
for _, s := range cfg.Servers {
if s.Name != identifier && s.Path != identifier {
newServers = append(newServers, s)
} else {
found = true
}
}
if !found {
return fmt.Errorf("server '%s' not found (try name or path)", identifier)
}
// Update viper
viper.Set("servers", newServers)
return SaveConfig()
}
// UpdateServer updates an existing server's configuration
func UpdateServer(identifier string, updates map[string]interface{}) error {
cfg, err := GetConfig()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
// Find the server (match by name or path)
found := false
for i, s := range cfg.Servers {
if s.Name == identifier || s.Path == identifier {
found = true
// Apply updates
if name, ok := updates["name"].(string); ok && name != "" {
cfg.Servers[i].Name = name
}
if calendarURL, ok := updates["calendar_url"].(string); ok && calendarURL != "" {
cfg.Servers[i].CalendarURL = calendarURL
}
if branch, ok := updates["branch"].(string); ok && branch != "" {
cfg.Servers[i].Branch = branch
}
if wipeBlueprints, ok := updates["wipe_blueprints"].(bool); ok {
cfg.Servers[i].WipeBlueprints = wipeBlueprints
}
if generateMap, ok := updates["generate_map"].(bool); ok {
cfg.Servers[i].GenerateMap = generateMap
}
break
}
}
if !found {
return fmt.Errorf("server '%s' not found (try name or path)", identifier)
}
// Update viper
viper.Set("servers", cfg.Servers)
return SaveConfig()
}
// ListServers returns all configured servers
func ListServers() ([]Server, error) {
cfg, err := GetConfig()
if err != nil {
return nil, fmt.Errorf("failed to get config: %w", err)
}
return cfg.Servers, nil
}
// SetCheckInterval sets the calendar check interval
func SetCheckInterval(seconds int) error {
if seconds < 10 {
return fmt.Errorf("check interval must be at least 10 seconds")
}
viper.Set("check_interval", seconds)
return SaveConfig()
}
// SetLookaheadHours sets the event lookahead window
func SetLookaheadHours(hours int) error {
if hours < 1 {
return fmt.Errorf("lookahead hours must be at least 1 hour")
}
viper.Set("lookahead_hours", hours)
return SaveConfig()
}
// SetDiscordWebhook sets the Discord webhook URL
func SetDiscordWebhook(url string) error {
viper.Set("discord_webhook", url)
return SaveConfig()
}
// SetEventDelay sets the event delay
func SetEventDelay(seconds int) error {
if seconds < 0 {
return fmt.Errorf("event delay must be at least 0 seconds")
}
viper.Set("event_delay", seconds)
return SaveConfig()
}
// SetMapGenerationHours sets how many hours before a wipe to generate maps
func SetMapGenerationHours(hours int) error {
if hours < 1 {
return fmt.Errorf("map generation hours must be at least 1 hour")
}
viper.Set("map_generation_hours", hours)
return SaveConfig()
}
// AddDiscordMentionUser adds a Discord user ID to the mention list
func AddDiscordMentionUser(userID string) error {
cfg, err := GetConfig()
if err != nil {
return err
}
// Check if already exists
for _, id := range cfg.DiscordMentionUsers {
if id == userID {
return fmt.Errorf("user ID %s already in mention list", userID)
}
}
cfg.DiscordMentionUsers = append(cfg.DiscordMentionUsers, userID)
viper.Set("discord_mention_users", cfg.DiscordMentionUsers)
return SaveConfig()
}
// RemoveDiscordMentionUser removes a Discord user ID from the mention list
func RemoveDiscordMentionUser(userID string) error {
cfg, err := GetConfig()
if err != nil {
return err
}
found := false
newList := []string{}
for _, id := range cfg.DiscordMentionUsers {
if id != userID {
newList = append(newList, id)
} else {
found = true
}
}
if !found {
return fmt.Errorf("user ID %s not found in mention list", userID)
}
viper.Set("discord_mention_users", newList)
return SaveConfig()
}
// AddDiscordMentionRole adds a Discord role ID to the mention list
func AddDiscordMentionRole(roleID string) error {
cfg, err := GetConfig()
if err != nil {
return err
}
// Check if already exists
for _, id := range cfg.DiscordMentionRoles {
if id == roleID {
return fmt.Errorf("role ID %s already in mention list", roleID)
}
}
cfg.DiscordMentionRoles = append(cfg.DiscordMentionRoles, roleID)
viper.Set("discord_mention_roles", cfg.DiscordMentionRoles)
return SaveConfig()
}
// RemoveDiscordMentionRole removes a Discord role ID from the mention list
func RemoveDiscordMentionRole(roleID string) error {
cfg, err := GetConfig()
if err != nil {
return err
}
found := false
newList := []string{}
for _, id := range cfg.DiscordMentionRoles {
if id != roleID {
newList = append(newList, id)
} else {
found = true
}
}
if !found {
return fmt.Errorf("role ID %s not found in mention list", roleID)
}
viper.Set("discord_mention_roles", newList)
return SaveConfig()
}
package daemon
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"sync"
"time"
"github.com/maintc/wipe-cli/internal/calendar"
"github.com/maintc/wipe-cli/internal/carbon"
"github.com/maintc/wipe-cli/internal/config"
"github.com/maintc/wipe-cli/internal/discord"
"github.com/maintc/wipe-cli/internal/executor"
"github.com/maintc/wipe-cli/internal/scheduler"
"github.com/maintc/wipe-cli/internal/steamcmd"
)
// Daemon represents the long-running service
type Daemon struct {
config *config.Config
scheduler *scheduler.Scheduler
lastUpdate time.Time
lastUpdateCheck time.Time
mapGenMutex sync.Mutex
mapGenInProgress bool
}
// New creates a new Daemon instance
func New() *Daemon {
return &Daemon{
lastUpdate: time.Time{},
lastUpdateCheck: time.Time{},
}
}
// Run starts the daemon's main loop
func (d *Daemon) Run(ctx context.Context) error {
log.Println("Daemon running...")
// Load initial config
cfg, err := config.GetConfig()
if err != nil {
log.Printf("Error loading initial config: %v", err)
return err
}
d.config = cfg
// Create scheduler
sched, err := scheduler.New(cfg.LookaheadHours, cfg.DiscordWebhook, cfg.EventDelay)
if err != nil {
log.Printf("Error creating scheduler: %v", err)
return err
}
d.scheduler = sched
// Ensure scheduler is shut down on exit
defer func() {
if d.scheduler != nil {
log.Println("Shutting down scheduler...")
if err := d.scheduler.Shutdown(); err != nil {
log.Printf("Error shutting down scheduler: %v", err)
}
}
}()
// Create pre-start hook script
if err := executor.EnsureHookScript(); err != nil {
log.Printf("Warning: Failed to create hook script: %v", err)
}
// Create wipe management scripts (stop-servers.sh, start-servers.sh, generate-maps.sh)
if err := executor.EnsureWipeScripts(); err != nil {
log.Printf("Warning: Failed to create wipe scripts: %v", err)
}
// Send startup notification
discord.SendInfo(cfg.DiscordWebhook, "Wipe Service Started",
fmt.Sprintf("Wipe daemon has started and is monitoring **%d** server(s)", len(cfg.Servers)))
// Ensure all servers are installed
if len(cfg.Servers) > 0 {
log.Printf("Checking server installations...")
d.ensureServersInstalled()
log.Printf("Performing initial calendar update...")
d.updateCalendars()
} else {
log.Printf("No servers configured")
}
// Ticker for reloading config (every 10 seconds)
configTicker := time.NewTicker(10 * time.Second)
defer configTicker.Stop()
// Ticker for checking updates (every 2 minutes)
updateCheckTicker := time.NewTicker(2 * time.Minute)
defer updateCheckTicker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-updateCheckTicker.C:
// Check for Rust updates
d.checkForUpdates()
case <-configTicker.C:
// Reload config
cfg, err := config.GetConfig()
if err != nil {
log.Printf("Error loading config: %v", err)
continue
}
// Detect server changes (additions/removals)
serversChanged := d.detectServerChanges(cfg)
d.config = cfg
// If servers changed, immediately update calendars
if serversChanged {
log.Printf("Server configuration changed, updating schedules...")
d.updateCalendars()
} else if d.shouldUpdateCalendars() {
// Otherwise, check if it's time for periodic update
d.updateCalendars()
}
}
}
}
// detectServerChanges checks if servers were added or removed
func (d *Daemon) detectServerChanges(newConfig *config.Config) bool {
if d.config == nil {
return false
}
// Build maps of server paths for comparison
oldServers := make(map[string]string)
newServers := make(map[string]string)
for _, s := range d.config.Servers {
oldServers[s.Path] = s.Name
}
for _, s := range newConfig.Servers {
newServers[s.Path] = s.Name
}
changed := false
// Check for removed servers
for path, name := range oldServers {
if _, exists := newServers[path]; !exists {
log.Printf("Server removed: %s (%s)", name, path)
discord.SendWarning(newConfig.DiscordWebhook, "Server Removed",
fmt.Sprintf("Server **%s** has been removed from monitoring\n\nPath: `%s`", name, path))
changed = true
}
}
// Check for added servers
for path, name := range newServers {
if _, exists := oldServers[path]; !exists {
log.Printf("Server added: %s (%s)", name, path)
discord.SendSuccess(newConfig.DiscordWebhook, "Server Added",
fmt.Sprintf("Server **%s** has been added to monitoring\n\nPath: `%s`", name, path))
changed = true
}
}
return changed
}
// shouldUpdateCalendars checks if enough time has passed to update calendars
func (d *Daemon) shouldUpdateCalendars() bool {
if d.config == nil {
return false
}
if len(d.config.Servers) == 0 {
return false
}
// Update if we've never updated, or if check_interval has passed
interval := time.Duration(d.config.CheckInterval) * time.Second
return d.lastUpdate.IsZero() || time.Since(d.lastUpdate) >= interval
}
// updateCalendars fetches and updates calendar events
func (d *Daemon) updateCalendars() {
log.Printf("Updating calendars for %d server(s)...", len(d.config.Servers))
if d.scheduler == nil {
sched, err := scheduler.New(d.config.LookaheadHours, d.config.DiscordWebhook, d.config.EventDelay)
if err != nil {
log.Printf("Error creating scheduler: %v", err)
return
}
d.scheduler = sched
}
// Update scheduler even if no servers (clears all events)
if err := d.scheduler.UpdateEvents(d.config.Servers); err != nil {
log.Printf("Error updating events: %v", err)
return
}
d.lastUpdate = time.Now()
if len(d.config.Servers) > 0 {
log.Printf("Next calendar update in %d seconds", d.config.CheckInterval)
} else {
log.Printf("No servers configured - monitoring stopped")
}
// Check if any maps need to be generated for upcoming wipes
go d.prepareWipeMaps()
}
// ensureServersInstalled ensures all configured Rust branches and Carbon are installed
func (d *Daemon) ensureServersInstalled() {
// Collect unique branches
branches := make(map[string]bool)
for _, server := range d.config.Servers {
if server.Branch != "" {
branches[server.Branch] = true
}
}
// Install each unique Rust branch
for branch := range branches {
if err := steamcmd.EnsureRustBranchInstalled(branch, d.config.DiscordWebhook); err != nil {
log.Printf("Error installing Rust branch '%s': %v", branch, err)
}
}
// Install Carbon for each branch
for branch := range branches {
if err := carbon.EnsureCarbonInstalled(branch, d.config.DiscordWebhook); err != nil {
log.Printf("Error installing Carbon for branch '%s': %v", branch, err)
}
}
}
// checkForUpdates checks all configured branches for available updates
func (d *Daemon) checkForUpdates() {
if d.config == nil {
return
}
// Collect unique branches
branches := make(map[string]bool)
for _, server := range d.config.Servers {
if server.Branch != "" {
branches[server.Branch] = true
}
}
if len(branches) == 0 {
return
}
log.Printf("Checking for Rust updates for %d branch(es)...", len(branches))
// Check each branch for Rust updates
for branch := range branches {
hasUpdate, buildID, err := steamcmd.CheckForUpdates(branch, d.config.DiscordWebhook)
if err != nil {
log.Printf("Error checking Rust updates for branch '%s': %v", branch, err)
continue
}
if hasUpdate {
log.Printf("Rust update detected for branch '%s', new build ID: %s", branch, buildID)
// Install the update
log.Printf("Installing Rust update for branch '%s'...", branch)
if err := steamcmd.InstallRustBranch(branch, d.config.DiscordWebhook); err != nil {
log.Printf("Error installing Rust update for branch '%s': %v", branch, err)
} else {
log.Printf("Successfully updated Rust branch '%s' to build %s", branch, buildID)
}
} else {
log.Printf("Rust branch '%s' is up to date (build: %s)", branch, buildID)
}
}
// Check each branch for Carbon updates
log.Printf("Checking for Carbon updates for %d branch(es)...", len(branches))
for branch := range branches {
hasUpdate, version, err := carbon.CheckForCarbonUpdates(branch, d.config.DiscordWebhook)
if err != nil {
log.Printf("Error checking Carbon updates for branch '%s': %v", branch, err)
continue
}
if hasUpdate {
log.Printf("Carbon update detected for branch '%s', new version: %s", branch, version)
// Install the update
log.Printf("Installing Carbon update for branch '%s'...", branch)
if err := carbon.InstallCarbon(branch, d.config.DiscordWebhook); err != nil {
log.Printf("Error installing Carbon update for branch '%s': %v", branch, err)
} else {
log.Printf("Successfully updated Carbon for branch '%s' to version %s", branch, version)
}
} else if version != "" {
log.Printf("Carbon for branch '%s' is up to date (version: %s)", branch, version)
}
}
d.lastUpdateCheck = time.Now()
}
// prepareWipeMaps checks for upcoming wipe events and calls generate-maps.sh if needed
func (d *Daemon) prepareWipeMaps() {
if d.config.MapGenerationHours == 0 || len(d.config.Servers) == 0 {
return
}
// Check if map generation is already in progress
d.mapGenMutex.Lock()
if d.mapGenInProgress {
d.mapGenMutex.Unlock()
log.Printf("Map generation already in progress, skipping")
return
}
d.mapGenInProgress = true
d.mapGenMutex.Unlock()
// Ensure we mark as complete when done
defer func() {
d.mapGenMutex.Lock()
d.mapGenInProgress = false
d.mapGenMutex.Unlock()
}()
// Get all scheduled events from the scheduler
events := d.scheduler.GetEvents()
// Build a map of servers with upcoming wipe events within the generation window
wipeWindow := time.Duration(d.config.MapGenerationHours) * time.Hour
serversNeedingMaps := make(map[string]bool)
for _, event := range events {
// Only process WIPE events
if event.Event.Type != calendar.EventTypeWipe {
continue
}
// Check if event is within the map generation window
timeUntilWipe := time.Until(event.Scheduled)
if timeUntilWipe > 0 && timeUntilWipe <= wipeWindow {
serversNeedingMaps[event.Server.Name] = true
}
}
// No wipes in the window? Nothing to do
if len(serversNeedingMaps) == 0 {
return
}
// Collect server paths that need maps and have generate_map enabled
var serverPathsToGenerate []string
for _, server := range d.config.Servers {
if !serversNeedingMaps[server.Name] {
continue // No wipe scheduled for this server
}
if !server.GenerateMap {
continue // Server doesn't want map generation
}
serverPathsToGenerate = append(serverPathsToGenerate, server.Path)
}
// Call generate-maps.sh script if there are servers needing map generation
if len(serverPathsToGenerate) > 0 {
log.Printf("Calling generate-maps.sh for %d server(s)...", len(serverPathsToGenerate))
if err := d.callGenerateMapsScript(serverPathsToGenerate); err != nil {
log.Printf("Error calling generate-maps.sh: %v", err)
discord.SendError(d.config.DiscordWebhook, "Map Generation Failed",
fmt.Sprintf("Failed to generate maps: %v", err))
}
}
}
// callGenerateMapsScript calls generate-maps.sh with server paths
func (d *Daemon) callGenerateMapsScript(serverPaths []string) error {
// Check if script exists
if _, err := os.Stat(executor.GenerateMapsScriptPath); err != nil {
return fmt.Errorf("generate-maps.sh not found at %s", executor.GenerateMapsScriptPath)
}
cmd := exec.Command(executor.GenerateMapsScriptPath, serverPaths...)
cmd.Stdout = log.Writer()
cmd.Stderr = log.Writer()
if err := cmd.Run(); err != nil {
return fmt.Errorf("script failed: %w", err)
}
return nil
}
package discord
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/maintc/wipe-cli/internal/config"
)
// Color constants for embed colors
const (
ColorSuccess = 0x00ff00 // Green
ColorInfo = 0x0099ff // Blue
ColorWarning = 0xff9900 // Orange
ColorError = 0xff0000 // Red
)
// EmbedField represents a field in a Discord embed
type EmbedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline,omitempty"`
}
// Embed represents a Discord embed
type Embed struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Color int `json:"color,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Fields []EmbedField `json:"fields,omitempty"`
Image *EmbedImage `json:"image,omitempty"`
Thumbnail *EmbedImage `json:"thumbnail,omitempty"`
Footer *EmbedFooter `json:"footer,omitempty"`
}
// EmbedImage represents an image in a Discord embed
type EmbedImage struct {
URL string `json:"url"`
}
// EmbedFooter represents a footer in a Discord embed
type EmbedFooter struct {
Text string `json:"text"`
IconURL string `json:"icon_url,omitempty"`
}
// WebhookPayload represents the Discord webhook payload
type WebhookPayload struct {
Content string `json:"content,omitempty"`
Embeds []Embed `json:"embeds,omitempty"`
}
// GetHostname returns the system hostname
func GetHostname() string {
hostname, err := os.Hostname()
if err != nil {
return "unknown"
}
return hostname
}
// SendNotification sends a Discord notification with an embed
func SendNotification(webhookURL, title, description string, color int) error {
if webhookURL == "" {
// Webhook not configured, skip silently
return nil
}
hostname := GetHostname()
// Load config to get mention IDs
cfg, err := config.GetConfig()
if err == nil {
// Build mention string inline if mentions are configured
userIDs := cfg.DiscordMentionUsers
roleIDs := cfg.DiscordMentionRoles
if len(userIDs) > 0 || len(roleIDs) > 0 {
mentions := []string{}
for _, roleID := range roleIDs {
mentions = append(mentions, fmt.Sprintf("<@&%s>", roleID))
}
for _, userID := range userIDs {
mentions = append(mentions, fmt.Sprintf("<@%s>", userID))
}
if len(mentions) > 0 {
mentionStr := "cc " + mentions[0]
for i := 1; i < len(mentions); i++ {
mentionStr += " " + mentions[i]
}
description = mentionStr + "\n\n" + description
}
}
}
embed := Embed{
Title: title,
Description: description,
Color: color,
Timestamp: time.Now().Format(time.RFC3339),
Fields: []EmbedField{
{
Name: "Hostname",
Value: hostname,
Inline: true,
},
},
}
payload := WebhookPayload{
Embeds: []Embed{embed},
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send webhook: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}
// SendSuccess sends a success notification (green)
func SendSuccess(webhookURL, title, description string) {
if err := SendNotification(webhookURL, title, description, ColorSuccess); err != nil {
log.Printf("Failed to send Discord success notification: %v", err)
}
}
// SendInfo sends an info notification (blue)
func SendInfo(webhookURL, title, description string) {
if err := SendNotification(webhookURL, title, description, ColorInfo); err != nil {
log.Printf("Failed to send Discord info notification: %v", err)
}
}
// SendWarning sends a warning notification (orange)
func SendWarning(webhookURL, title, description string) {
if err := SendNotification(webhookURL, title, description, ColorWarning); err != nil {
log.Printf("Failed to send Discord warning notification: %v", err)
}
}
// SendError sends an error notification (red)
func SendError(webhookURL, title, description string) {
if err := SendNotification(webhookURL, title, description, ColorError); err != nil {
log.Printf("Failed to send Discord error notification: %v", err)
}
}
package executor
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/maintc/wipe-cli/internal/carbon"
"github.com/maintc/wipe-cli/internal/config"
"github.com/maintc/wipe-cli/internal/discord"
"github.com/maintc/wipe-cli/internal/steamcmd"
)
var (
HookScriptPath = "/opt/wiped/pre-start-hook.sh"
StopServersScriptPath = "/opt/wiped/stop-servers.sh"
StartServersScriptPath = "/opt/wiped/start-servers.sh"
GenerateMapsScriptPath = "/opt/wiped/generate-maps.sh"
)
// EnsureHookScript creates the pre-start hook script if it doesn't exist
func EnsureHookScript() error {
hookDir := filepath.Dir(HookScriptPath)
if err := os.MkdirAll(hookDir, 0755); err != nil {
return fmt.Errorf("failed to create hook directory: %w", err)
}
// Check if script already exists
if _, err := os.Stat(HookScriptPath); err == nil {
return nil
}
content := `#!/bin/bash
# Pre-start Hook Script
#
# This script is executed once after all servers have been synced
# but before any servers are started back up.
#
# Arguments passed to this script:
# $@ - Space-separated list of server paths involved in this event
#
# Example:
# /var/www/servers/us-weekly /var/www/servers/eu-monthly
#
# You can add any custom logic here that should run before servers start.
# For example: clearing caches, updating plugins, sending notifications, etc.
SERVER_PATHS="$@"
echo "Pre-start hook executed for servers: $SERVER_PATHS"
# Add your custom logic below this line
# ...
`
if err := os.WriteFile(HookScriptPath, []byte(content), 0755); err != nil {
return fmt.Errorf("failed to write hook script: %w", err)
}
log.Printf("Created pre-start hook script at %s", HookScriptPath)
return nil
}
// EnsureWipeScripts creates the wipe management scripts if they don't exist
func EnsureWipeScripts() error {
scriptsDir := filepath.Dir(StopServersScriptPath)
if err := os.MkdirAll(scriptsDir, 0755); err != nil {
return fmt.Errorf("failed to create scripts directory: %w", err)
}
// Ensure stop-servers.sh
if err := ensureStopServersScript(); err != nil {
return err
}
// Ensure start-servers.sh
if err := ensureStartServersScript(); err != nil {
return err
}
// Ensure generate-maps.sh
if err := ensureGenerateMapsScript(); err != nil {
return err
}
return nil
}
func ensureStopServersScript() error {
// Check if script already exists
if _, err := os.Stat(StopServersScriptPath); err == nil {
return nil
}
content := `#!/bin/bash
# Stop Servers Script
#
# This script is called to stop Rust servers before performing updates/wipes.
#
# Arguments passed to this script:
# $@ - Space-separated list of server paths
#
# Example:
# /var/www/servers/us-weekly /var/www/servers/eu-monthly
#
# Customize this script to match your server management approach.
SERVER_PATHS="$@"
echo "Stopping servers for paths: $SERVER_PATHS"
for SERVER_PATH in $SERVER_PATHS; do
# Extract server identity from path (e.g., us-weekly from /var/www/servers/us-weekly)
IDENTITY=$(basename "$SERVER_PATH")
echo "Stopping server: $IDENTITY (path: $SERVER_PATH)"
# Add your server stop logic here
# Examples:
# - systemctl stop rs-${IDENTITY}
# - docker stop ${IDENTITY}
# - kill $(cat ${SERVER_PATH}/server.pid)
# - your custom stop command
done
echo "✓ All servers stopped"
`
if err := os.WriteFile(StopServersScriptPath, []byte(content), 0755); err != nil {
return fmt.Errorf("failed to write stop-servers script: %w", err)
}
log.Printf("Created stop-servers script at %s", StopServersScriptPath)
return nil
}
func ensureStartServersScript() error {
// Check if script already exists
if _, err := os.Stat(StartServersScriptPath); err == nil {
return nil
}
content := `#!/bin/bash
# Start Servers Script
#
# This script is called to start Rust servers after performing updates/wipes.
#
# Arguments passed to this script:
# $@ - Space-separated list of server paths
#
# Example:
# /var/www/servers/us-weekly /var/www/servers/eu-monthly
#
# Customize this script to match your server management approach.
SERVER_PATHS="$@"
echo "Starting servers for paths: $SERVER_PATHS"
for SERVER_PATH in $SERVER_PATHS; do
# Extract server identity from path (e.g., us-weekly from /var/www/servers/us-weekly)
IDENTITY=$(basename "$SERVER_PATH")
echo "Starting server: $IDENTITY (path: $SERVER_PATH)"
# Add your server start logic here
# Examples:
# - systemctl start rs-${IDENTITY}
# - docker start ${IDENTITY}
# - ${SERVER_PATH}/start.sh
# - your custom start command
done
echo "✓ All servers started"
`
if err := os.WriteFile(StartServersScriptPath, []byte(content), 0755); err != nil {
return fmt.Errorf("failed to write start-servers script: %w", err)
}
log.Printf("Created start-servers script at %s", StartServersScriptPath)
return nil
}
func ensureGenerateMapsScript() error {
// Check if script already exists
if _, err := os.Stat(GenerateMapsScriptPath); err == nil {
return nil
}
content := `#!/bin/bash
# Generate Maps Script
#
# This script is called to prepare maps for Rust servers before wipes.
# It runs 22 hours before a wipe event (configurable via map_generation_hours).
#
# Arguments passed to this script:
# $@ - Space-separated list of server paths that need maps prepared
#
# Example:
# /var/www/servers/us-weekly /var/www/servers/eu-monthly
#
# YOUR RESPONSIBILITIES:
# 1. Pick or generate a map (seed/size, custom map, etc.)
# 2. Update the server's server.cfg file with map settings:
# - server.seed and server.size (for procedural maps)
# - OR server.levelurl (for custom map providers)
# 3. Handle any map-related files as needed
# 4. Clean up any temporary files after the wipe completes
# 5. Exit with non-zero status on failure
#
# NOTE: This script is called BEFORE the wipe. The actual wipe process will:
# - Stop servers
# - Sync Rust/Carbon
# - Delete map/save files
# - Run pre-start-hook.sh
# - Start servers
#
# You are responsible for updating server.cfg BEFORE the wipe or in pre-start-hook.sh
SERVER_PATHS="$@"
echo "Map preparation requested for paths: $SERVER_PATHS"
for SERVER_PATH in $SERVER_PATHS; do
# Extract server identity from path (e.g., us-weekly from /var/www/servers/us-weekly)
IDENTITY=$(basename "$SERVER_PATH")
echo "Preparing map for: $IDENTITY (path: $SERVER_PATH)"
# Add your map preparation logic here
# Examples:
#
# Option 1: Pick random seed/size and update server.cfg
# SEED=$RANDOM
# SIZE=4250
# echo "server.seed \"$SEED\"" >> ${SERVER_PATH}/server/${IDENTITY}/cfg/server.cfg
# echo "server.size $SIZE" >> ${SERVER_PATH}/server/${IDENTITY}/cfg/server.cfg
#
# Option 2: Generate with a custom map generator and update server.cfg
# /usr/local/bin/map-generator --seed $SEED --size $SIZE --output ${SERVER_PATH}/maps
# LEVELURL=$(cat ${SERVER_PATH}/maps/level_url.txt)
# echo "server.levelurl \"$LEVELURL\"" >> ${SERVER_PATH}/server/${IDENTITY}/cfg/server.cfg
#
# Option 3: Do nothing, let server use default map
# echo "Using default map for $IDENTITY"
done
echo "✓ Map preparation complete"
`
if err := os.WriteFile(GenerateMapsScriptPath, []byte(content), 0755); err != nil {
return fmt.Errorf("failed to write generate-maps script: %w", err)
}
log.Printf("Created generate-maps script at %s", GenerateMapsScriptPath)
return nil
}
// ExecuteEventBatch processes multiple servers together (mix of restarts and wipes)
func ExecuteEventBatch(servers []config.Server, wipeServers map[string]bool, webhookURL string, eventDelay int) error {
wipeCount := len(wipeServers)
restartCount := len(servers) - wipeCount
log.Printf("Executing batch event for %d server(s): %d restart(s), %d wipe(s)", len(servers), restartCount, wipeCount)
// Wait for configured delay
if eventDelay > 0 {
log.Printf("Waiting %d seconds before executing...", eventDelay)
time.Sleep(time.Duration(eventDelay) * time.Second)
}
// Send Discord notification: Starting
serverNames := make([]string, len(servers))
for i, s := range servers {
serverNames[i] = s.Name
}
discord.SendInfo(webhookURL, "Batch Event Starting",
fmt.Sprintf("Starting batch event for **%d** server(s):\n• %s\n\n**%d restart(s), %d wipe(s)**",
len(servers), strings.Join(serverNames, "\n• "), restartCount, wipeCount))
// Step 1: Stop all servers at once
serverPaths := make([]string, len(servers))
for i, s := range servers {
serverPaths[i] = s.Path
}
log.Printf("Stopping %d server(s)...", len(servers))
if err := stopServers(serverPaths); err != nil {
errMsg := fmt.Sprintf("Failed to stop servers: %v", err)
log.Printf("Error: %s", errMsg)
discord.SendError(webhookURL, "Batch Event Failed", errMsg)
return fmt.Errorf("%s", errMsg)
}
// Step 2: Update Rust and Carbon for all servers (in parallel)
log.Printf("Updating Rust and Carbon on servers...")
if err := SyncServers(servers); err != nil {
errMsg := fmt.Sprintf("Failed to update servers: %v", err)
log.Printf("Error: %s", errMsg)
discord.SendError(webhookURL, "Batch Event Failed", errMsg)
return fmt.Errorf("%s", errMsg)
}
// Step 3: Wipe data for wipe-servers only
if len(wipeServers) > 0 {
log.Printf("Performing wipe cleanup for %d server(s)...", len(wipeServers))
for _, server := range servers {
if wipeServers[server.Path] {
log.Printf(" Wiping data for %s", server.Name)
if err := wipeServerData(server); err != nil {
errMsg := fmt.Sprintf("Failed to wipe data for server %s: %v", server.Name, err)
log.Printf("Error: %s", errMsg)
discord.SendError(webhookURL, "Batch Event Failed", errMsg)
return fmt.Errorf("%s", errMsg)
}
}
}
}
// Step 4: Run pre-start hook once with all server paths
if err := runPreStartHook(serverPaths); err != nil {
log.Printf("Warning: Pre-start hook failed: %v", err)
// Don't fail the entire operation if hook fails
}
// Step 5: Start all servers at once
log.Printf("Starting %d server(s)...", len(servers))
if err := startServers(serverPaths); err != nil {
errMsg := fmt.Sprintf("Failed to start servers: %v", err)
log.Printf("Error: %s", errMsg)
discord.SendError(webhookURL, "Batch Event Failed", errMsg)
return fmt.Errorf("%s", errMsg)
}
// Success notification
discord.SendSuccess(webhookURL, "Batch Event Complete",
fmt.Sprintf("Successfully completed batch event for **%d** server(s):\n• %s\n\n**%d restart(s), %d wipe(s)**",
len(servers), strings.Join(serverNames, "\n• "), restartCount, wipeCount))
log.Printf("✓ Batch event completed successfully")
return nil
}
// stopServers stops servers via stop-servers.sh
func stopServers(serverPaths []string) error {
// Check if script exists
if _, err := os.Stat(StopServersScriptPath); err != nil {
return fmt.Errorf("stop-servers.sh not found at %s", StopServersScriptPath)
}
cmd := exec.Command(StopServersScriptPath, serverPaths...)
cmd.Stdout = log.Writer()
cmd.Stderr = log.Writer()
if err := cmd.Run(); err != nil {
return fmt.Errorf("stop script failed: %w", err)
}
return nil
}
// startServers starts servers via start-servers.sh
func startServers(serverPaths []string) error {
// Check if script exists
if _, err := os.Stat(StartServersScriptPath); err != nil {
return fmt.Errorf("start-servers.sh not found at %s", StartServersScriptPath)
}
cmd := exec.Command(StartServersScriptPath, serverPaths...)
cmd.Stdout = log.Writer()
cmd.Stderr = log.Writer()
if err := cmd.Run(); err != nil {
return fmt.Errorf("start script failed: %w", err)
}
return nil
}
// SyncServers updates Rust and Carbon installations on multiple servers in parallel
func SyncServers(servers []config.Server) error {
type result struct {
server config.Server
err error
}
results := make(chan result, len(servers))
var wg sync.WaitGroup
// Launch parallel sync operations
for _, server := range servers {
wg.Add(1)
go func(s config.Server) {
defer wg.Done()
err := syncServer(s)
results <- result{server: s, err: err}
}(server)
}
// Wait for all syncs to complete
go func() {
wg.Wait()
close(results)
}()
// Collect results and check for errors
var errors []string
for res := range results {
if res.err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", res.server.Name, res.err))
}
}
if len(errors) > 0 {
return fmt.Errorf("failed to update servers:\n - %s", strings.Join(errors, "\n - "))
}
return nil
}
// syncServer updates Rust and Carbon installations on the server
func syncServer(server config.Server) error {
log.Printf("Updating server: %s", server.Name)
// Acquire READ locks for this branch to prevent reading during install/update
// These will block if InstallRustBranch/InstallCarbon are currently running
branch := server.Branch
if branch == "" {
branch = "main"
}
rustUnlock := steamcmd.AcquireReadLock(branch)
defer rustUnlock()
carbonUnlock := carbon.AcquireReadLock(branch)
defer carbonUnlock()
// Determine source paths based on branch
rustSource := filepath.Join("/opt/rust", branch)
carbonSource := filepath.Join("/opt/carbon", branch)
// Update Rust
log.Printf(" Updating Rust from %s to %s", rustSource, server.Path)
// Remove old Rust files first
rustCleanupDirs := []string{
filepath.Join(server.Path, "RustDedicated_Data"),
filepath.Join(server.Path, "Bundles"),
filepath.Join(server.Path, "steamapps"),
filepath.Join(server.Path, "steamcmd"),
}
for _, dir := range rustCleanupDirs {
if err := os.RemoveAll(dir); err != nil {
log.Printf(" Warning: Failed to remove %s: %v", dir, err)
}
}
// Rsync Rust (safe mode: uses temp files for atomic updates)
rsyncCmd := exec.Command("rsync", "-a", fmt.Sprintf("%s/", rustSource), fmt.Sprintf("%s/", server.Path))
output, err := rsyncCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("rust rsync failed: %w\nOutput: %s", err, output)
}
// Update Carbon
log.Printf(" Updating Carbon from %s to %s", carbonSource, server.Path)
// Remove old Carbon files first
carbonCleanupDirs := []string{
filepath.Join(server.Path, "carbon", "native"),
filepath.Join(server.Path, "carbon", "managed"),
filepath.Join(server.Path, "carbon", "tools"),
}
for _, dir := range carbonCleanupDirs {
if err := os.RemoveAll(dir); err != nil {
log.Printf(" Warning: Failed to remove %s: %v", dir, err)
}
}
// Rsync Carbon (safe mode: uses temp files for atomic updates)
rsyncCmd = exec.Command("rsync", "-a", fmt.Sprintf("%s/", carbonSource), fmt.Sprintf("%s/", server.Path))
output, err = rsyncCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("carbon rsync failed: %w\nOutput: %s", err, output)
}
log.Printf(" ✓ Updated %s", server.Name)
return nil
}
// wipeServerData deletes map/save files for a wipe event
func wipeServerData(server config.Server) error {
log.Printf("Wiping data for server: %s", server.Name)
// Extract server identity from path (last component)
identity := filepath.Base(server.Path)
serverDataPath := filepath.Join(server.Path, "server", identity)
log.Printf(" Server data path: %s", serverDataPath)
// Patterns to delete
patterns := []string{
"*.map",
"*.sav*",
"player.states.*.db*",
"sv.files.*.db*",
}
// Conditionally add blueprints
if server.WipeBlueprints {
log.Printf(" Including blueprints in wipe")
patterns = append(patterns, "player.blueprints.*")
}
// Delete matching files
for _, pattern := range patterns {
matches, err := filepath.Glob(filepath.Join(serverDataPath, pattern))
if err != nil {
log.Printf(" Warning: Failed to glob pattern %s: %v", pattern, err)
continue
}
for _, match := range matches {
log.Printf(" Deleting: %s", match)
if err := os.Remove(match); err != nil {
log.Printf(" Warning: Failed to delete %s: %v", match, err)
}
}
}
log.Printf(" ✓ Wiped data for %s", server.Name)
return nil
}
// runPreStartHook executes the pre-start hook script with server paths as arguments
func runPreStartHook(serverPaths []string) error {
log.Printf("Running pre-start hook: %s", HookScriptPath)
cmd := exec.Command(HookScriptPath, serverPaths...)
cmd.Stdout = log.Writer()
cmd.Stderr = log.Writer()
if err := cmd.Run(); err != nil {
return fmt.Errorf("hook script failed: %w", err)
}
return nil
}
package scheduler
import (
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/maintc/wipe-cli/internal/calendar"
"github.com/maintc/wipe-cli/internal/config"
"github.com/maintc/wipe-cli/internal/discord"
"github.com/maintc/wipe-cli/internal/executor"
)
// ScheduledEvent represents an event with server context
type ScheduledEvent struct {
Server config.Server
Event calendar.Event
Scheduled time.Time
}
// Scheduler manages scheduled events using gocron
type Scheduler struct {
gocron gocron.Scheduler
events []ScheduledEvent
lookaheadHours int
webhookURL string
eventDelay int
scheduledJobs map[string]uuid.UUID // Track gocron job IDs by time key
jobEvents map[string][]ScheduledEvent // Mutable event list per job (updated on calendar refresh)
executingJobs map[string]bool // Track which jobs are currently executing (by timeKey)
mutex sync.Mutex
}
// New creates a new Scheduler
func New(lookaheadHours int, webhookURL string, eventDelay int) (*Scheduler, error) {
gocronScheduler, err := gocron.NewScheduler()
if err != nil {
return nil, fmt.Errorf("failed to create gocron scheduler: %w", err)
}
s := &Scheduler{
gocron: gocronScheduler,
events: make([]ScheduledEvent, 0),
lookaheadHours: lookaheadHours,
webhookURL: webhookURL,
eventDelay: eventDelay,
scheduledJobs: make(map[string]uuid.UUID),
jobEvents: make(map[string][]ScheduledEvent),
executingJobs: make(map[string]bool),
}
// Start the gocron scheduler
s.gocron.Start()
return s, nil
}
// Shutdown gracefully shuts down the scheduler
func (s *Scheduler) Shutdown() error {
return s.gocron.Shutdown()
}
// GetEvents returns a copy of the current events (thread-safe)
func (s *Scheduler) GetEvents() []ScheduledEvent {
s.mutex.Lock()
defer s.mutex.Unlock()
// Return a copy to prevent external modification
eventsCopy := make([]ScheduledEvent, len(s.events))
copy(eventsCopy, s.events)
return eventsCopy
}
// UpdateEvents fetches calendars and updates the schedule
func (s *Scheduler) UpdateEvents(servers []config.Server) error {
s.mutex.Lock()
defer s.mutex.Unlock()
log.Println("Updating calendar events...")
var allEvents []ScheduledEvent
for _, server := range servers {
log.Printf("Fetching calendar for %s...", server.Name)
cal, err := calendar.FetchCalendar(server.CalendarURL)
if err != nil {
log.Printf("Error fetching calendar for %s: %v", server.Name, err)
continue
}
events, err := calendar.GetUpcomingEvents(cal, s.lookaheadHours)
if err != nil {
log.Printf("Error parsing events for %s: %v", server.Name, err)
continue
}
log.Printf("Found %d upcoming event(s) for %s", len(events), server.Name)
for _, event := range events {
allEvents = append(allEvents, ScheduledEvent{
Server: server,
Event: event,
Scheduled: event.StartTime,
})
}
}
// Resolve conflicts (same server, same time, wipe takes precedence)
allEvents = s.resolveConflicts(allEvents)
// Sort by time
sort.Slice(allEvents, func(i, j int) bool {
return allEvents[i].Scheduled.Before(allEvents[j].Scheduled)
})
// Detect changes
oldEvents := s.events
s.detectEventChanges(oldEvents, allEvents)
s.events = allEvents
// Group events by time (truncated to minute) and schedule gocron jobs
if err := s.scheduleJobs(); err != nil {
return fmt.Errorf("failed to schedule jobs: %w", err)
}
log.Printf("Total scheduled events: %d", len(s.events))
s.logUpcomingEvents()
return nil
}
// resolveConflicts removes restart events if a wipe event exists at the same time
func (s *Scheduler) resolveConflicts(events []ScheduledEvent) []ScheduledEvent {
// Group by server path and time
type key struct {
serverPath string
time string // Use string representation for grouping
}
eventMap := make(map[key][]ScheduledEvent)
for _, event := range events {
k := key{
serverPath: event.Server.Path,
time: event.Scheduled.Format(time.RFC3339),
}
eventMap[k] = append(eventMap[k], event)
}
var resolved []ScheduledEvent
for _, group := range eventMap {
if len(group) == 1 {
resolved = append(resolved, group[0])
continue
}
// If multiple events at same time, prefer wipe over restart
hasWipe := false
var wipeEvent ScheduledEvent
for _, event := range group {
if event.Event.Type == calendar.EventTypeWipe {
hasWipe = true
wipeEvent = event
break
}
}
if hasWipe {
resolved = append(resolved, wipeEvent)
log.Printf("Conflict resolved: Wipe takes precedence for %s at %s",
wipeEvent.Server.Name, wipeEvent.Scheduled.Format(time.RFC3339))
} else {
// All restarts, just take the first one
resolved = append(resolved, group[0])
}
}
return resolved
}
// detectEventChanges compares old and new events and sends Discord notifications for changes
func (s *Scheduler) detectEventChanges(oldEvents, newEvents []ScheduledEvent) {
// Build maps for comparison using a unique key for each event
oldEventMap := make(map[string]ScheduledEvent)
newEventMap := make(map[string]ScheduledEvent)
for _, event := range oldEvents {
key := fmt.Sprintf("%s|%s|%s", event.Server.Path, event.Event.Type, event.Scheduled.Format(time.RFC3339))
oldEventMap[key] = event
}
for _, event := range newEvents {
key := fmt.Sprintf("%s|%s|%s", event.Server.Path, event.Event.Type, event.Scheduled.Format(time.RFC3339))
newEventMap[key] = event
}
// Find added events
var added []ScheduledEvent
for key, event := range newEventMap {
if _, exists := oldEventMap[key]; !exists {
added = append(added, event)
}
}
// Find removed events
var removed []ScheduledEvent
for key, event := range oldEventMap {
if _, exists := newEventMap[key]; !exists {
removed = append(removed, event)
}
}
// Send notifications for added events
if len(added) > 0 {
s.notifyEventsAdded(added)
}
// Send notifications for removed events
if len(removed) > 0 {
s.notifyEventsRemoved(removed)
}
}
// notifyEventsAdded sends Discord notification for newly added events
func (s *Scheduler) notifyEventsAdded(events []ScheduledEvent) {
if s.webhookURL == "" {
return
}
// Group by event type
restarts := []string{}
wipes := []string{}
for _, event := range events {
timeStr := event.Scheduled.Format("Mon Jan 02 15:04 MST")
eventStr := fmt.Sprintf("%s at %s", event.Server.Name, timeStr)
if event.Event.Type == calendar.EventTypeWipe {
wipes = append(wipes, eventStr)
} else {
restarts = append(restarts, eventStr)
}
}
var description strings.Builder
description.WriteString(fmt.Sprintf("**%d** new event(s) scheduled:\n\n", len(events)))
if len(restarts) > 0 {
description.WriteString("**Restarts:**\n")
for _, r := range restarts {
description.WriteString(fmt.Sprintf("• %s\n", r))
}
if len(wipes) > 0 {
description.WriteString("\n")
}
}
if len(wipes) > 0 {
description.WriteString("**Wipes:**\n")
for _, w := range wipes {
description.WriteString(fmt.Sprintf("• %s\n", w))
}
}
log.Printf("Calendar events added: %d", len(events))
discord.SendSuccess(s.webhookURL, "Calendar Events Added", description.String())
}
// notifyEventsRemoved sends Discord notification for removed events
func (s *Scheduler) notifyEventsRemoved(events []ScheduledEvent) {
if s.webhookURL == "" {
return
}
// Group by event type
restarts := []string{}
wipes := []string{}
for _, event := range events {
timeStr := event.Scheduled.Format("Mon Jan 02 15:04 MST")
eventStr := fmt.Sprintf("%s at %s", event.Server.Name, timeStr)
if event.Event.Type == calendar.EventTypeWipe {
wipes = append(wipes, eventStr)
} else {
restarts = append(restarts, eventStr)
}
}
var description strings.Builder
description.WriteString(fmt.Sprintf("**%d** event(s) removed:\n\n", len(events)))
if len(restarts) > 0 {
description.WriteString("**Restarts:**\n")
for _, r := range restarts {
description.WriteString(fmt.Sprintf("• %s\n", r))
}
if len(wipes) > 0 {
description.WriteString("\n")
}
}
if len(wipes) > 0 {
description.WriteString("**Wipes:**\n")
for _, w := range wipes {
description.WriteString(fmt.Sprintf("• %s\n", w))
}
}
log.Printf("Calendar events removed: %d", len(events))
discord.SendWarning(s.webhookURL, "Calendar Events Removed", description.String())
}
// logUpcomingEvents prints a summary of upcoming events
func (s *Scheduler) logUpcomingEvents() {
if len(s.events) == 0 {
log.Println("No upcoming events in the next", s.lookaheadHours, "hours")
return
}
log.Println("Upcoming events:")
for _, event := range s.events {
timeUntil := time.Until(event.Scheduled).Round(time.Minute)
log.Printf(" %s - %s [%s] (in %s)",
event.Scheduled.Format("Mon Jan 02 15:04 MST"),
event.Server.Name,
event.Event.Type,
timeUntil)
}
}
// scheduleJobs groups events by time and creates gocron jobs for each time-group
func (s *Scheduler) scheduleJobs() error {
// Group events by time (truncated to minute)
eventGroups := make(map[string][]ScheduledEvent)
timeKeys := make(map[string]time.Time)
for _, event := range s.events {
timeKey := event.Scheduled.Truncate(time.Minute).Format(time.RFC3339)
eventGroups[timeKey] = append(eventGroups[timeKey], event)
if _, exists := timeKeys[timeKey]; !exists {
timeKeys[timeKey] = event.Scheduled.Truncate(time.Minute)
}
}
// Build set of current time keys
currentTimeKeys := make(map[string]bool)
for timeKey := range eventGroups {
currentTimeKeys[timeKey] = true
}
// Update event lists for existing jobs AND schedule new jobs
for timeKey, events := range eventGroups {
scheduleTime := timeKeys[timeKey]
// Skip events in the past
if scheduleTime.Before(time.Now()) {
log.Printf("Skipping past event at %s", timeKey)
continue
}
// Make a copy of events for this time group
eventsCopy := make([]ScheduledEvent, len(events))
copy(eventsCopy, events)
// Check if job already scheduled
if _, exists := s.scheduledJobs[timeKey]; exists {
// Job exists - UPDATE the event list (allows add/remove of individual servers)
s.jobEvents[timeKey] = eventsCopy
log.Printf("Updated event list for %s (%d server(s))",
scheduleTime.Format("Mon Jan 02 15:04 MST"), len(events))
continue
}
// Job doesn't exist - CREATE new job
// Store the event list
s.jobEvents[timeKey] = eventsCopy
// Schedule one job for this time-group
// Pass timeKey so we can look up current events at execution time
tk := timeKey // Capture for closure
job, err := s.gocron.NewJob(
gocron.OneTimeJob(
gocron.OneTimeJobStartDateTime(scheduleTime),
),
gocron.NewTask(
func() {
// Mark as executing IMMEDIATELY to prevent cancellation during UpdateEvents
s.mutex.Lock()
s.executingJobs[tk] = true
currentEvents, exists := s.jobEvents[tk]
s.mutex.Unlock()
// Ensure we remove the executing mark when done
defer func() {
s.mutex.Lock()
delete(s.executingJobs, tk)
s.mutex.Unlock()
}()
if !exists || len(currentEvents) == 0 {
log.Printf("No events found for %s at execution time, skipping", tk)
return
}
// Execute without re-marking (already marked above)
s.executeEventGroupInternal(currentEvents)
},
),
gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
if err != nil {
return fmt.Errorf("failed to schedule job for %s: %w", timeKey, err)
}
s.scheduledJobs[timeKey] = job.ID()
log.Printf("Scheduled job for %s (%d server(s))",
scheduleTime.Format("Mon Jan 02 15:04 MST"), len(events))
}
// Cancel jobs that are no longer needed (timeKey completely gone)
for timeKey, jobID := range s.scheduledJobs {
if !currentTimeKeys[timeKey] {
// Check if this job is currently executing
// Never cancel a job that's in progress
if s.executingJobs[timeKey] {
log.Printf("Keeping job for %s (currently executing)", timeKey)
continue
}
if err := s.gocron.RemoveJob(jobID); err != nil {
log.Printf("Warning: failed to remove job for %s: %v", timeKey, err)
}
delete(s.scheduledJobs, timeKey)
delete(s.jobEvents, timeKey)
log.Printf("Cancelled job for time: %s", timeKey)
}
}
return nil
}
// executeEventGroupInternal performs the actual event execution
// Note: The gocron job closure handles marking executingJobs before calling this
func (s *Scheduler) executeEventGroupInternal(events []ScheduledEvent) {
if len(events) == 0 {
return
}
// Process all events together (restarts and wipes in single batch)
// Extract all servers
servers := make([]config.Server, len(events))
wipeServers := make(map[string]bool) // Track which servers need wipe
for i, event := range events {
servers[i] = event.Server
if event.Event.Type == calendar.EventTypeWipe {
wipeServers[event.Server.Path] = true
}
}
// Execute all servers together, passing which ones need wipes
if err := executor.ExecuteEventBatch(servers, wipeServers, s.webhookURL, s.eventDelay); err != nil {
log.Printf("Error executing event group: %v", err)
}
}
package steamcmd
import (
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/maintc/wipe-cli/internal/discord"
)
const (
SteamCMDURL = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz"
RustAppID = "258550"
RustInstallBase = "/opt/rust"
SteamCMDBase = "/opt/rust/steamcmd"
)
var (
// installMutex prevents concurrent steamcmd operations
installMutex sync.Mutex
// installingBranches tracks which branches are currently being installed/updated
installingBranches = make(map[string]bool)
installingMutex sync.Mutex
// branchLocks provides per-branch RW locks to coordinate installs vs syncs
branchLocks = make(map[string]*sync.RWMutex)
branchMutex sync.Mutex
)
// EnsureRustBranchInstalled checks if a Rust branch is installed and installs it if not
func EnsureRustBranchInstalled(branch, webhookURL string) error {
installPath := getRustInstallPath(branch)
// Check if branch is already installed
if isRustInstalled(installPath) {
log.Printf("Rust branch '%s' already installed at %s", branch, installPath)
return nil
}
log.Printf("Rust branch '%s' not found at %s, installing...", branch, installPath)
return InstallRustBranch(branch, webhookURL)
}
// InstallRustBranch installs a Rust branch using steamcmd
func InstallRustBranch(branch, webhookURL string) error {
// Check if this branch is already being installed
installingMutex.Lock()
if installingBranches[branch] {
installingMutex.Unlock()
log.Printf("Branch '%s' is already being installed, skipping", branch)
return nil
}
installingBranches[branch] = true
installingMutex.Unlock()
// Ensure we mark installation as complete when done
defer func() {
installingMutex.Lock()
delete(installingBranches, branch)
installingMutex.Unlock()
}()
// Acquire WRITE lock for this branch to block syncServer reads during install
branchLock := getBranchLock(branch)
branchLock.Lock()
defer branchLock.Unlock()
// Acquire global install mutex to prevent concurrent steamcmd operations
installMutex.Lock()
defer installMutex.Unlock()
installPath := getRustInstallPath(branch)
log.Printf("Installing Rust branch '%s' to %s", branch, installPath)
// Create base rust directory
if err := os.MkdirAll(RustInstallBase, 0755); err != nil {
errMsg := fmt.Sprintf("failed to create rust base directory: %v", err)
discord.SendError(webhookURL, "Rust Installation Failed", fmt.Sprintf("Failed to install Rust branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Create branch install directory
if err := os.MkdirAll(installPath, 0755); err != nil {
errMsg := fmt.Sprintf("failed to create branch directory: %v", err)
discord.SendError(webhookURL, "Rust Installation Failed", fmt.Sprintf("Failed to install Rust branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Setup steamcmd (shared across all branches)
if err := setupSteamCMD(); err != nil {
errMsg := fmt.Sprintf("failed to setup steamcmd: %v", err)
discord.SendError(webhookURL, "Rust Installation Failed", fmt.Sprintf("Failed to install Rust branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Create buildid file
buildidPath := filepath.Join(installPath, "buildid")
if err := os.WriteFile(buildidPath, []byte(""), 0644); err != nil {
errMsg := fmt.Sprintf("failed to create buildid file: %v", err)
discord.SendError(webhookURL, "Rust Installation Failed", fmt.Sprintf("Failed to install Rust branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Read old buildid
oldBuildID := ""
if data, err := os.ReadFile(buildidPath); err == nil {
oldBuildID = string(data)
}
// Install/update the branch
if err := updateRustBranch(branch, installPath); err != nil {
errMsg := fmt.Sprintf("failed to update Rust branch: %v", err)
discord.SendError(webhookURL, "Rust Installation Failed", fmt.Sprintf("Failed to install Rust branch **%s**\n\n%s", branch, errMsg))
return fmt.Errorf("%s", errMsg)
}
// Read new buildid
newBuildID := ""
if data, err := os.ReadFile(buildidPath); err == nil {
newBuildID = string(data)
}
// Send success notification
log.Printf("✓ Successfully installed Rust branch '%s'", branch)
if oldBuildID == "" {
discord.SendSuccess(webhookURL, "Rust Installation Complete",
fmt.Sprintf("Rust branch **%s** installed successfully\n\nBuild ID: **%s**", branch, newBuildID))
} else if oldBuildID != newBuildID {
discord.SendSuccess(webhookURL, "Rust Update Complete",
fmt.Sprintf("Rust branch **%s** updated\n\nFrom: **%s**\nTo: **%s**", branch, oldBuildID, newBuildID))
}
return nil
}
// getBranchLock gets or creates an RWMutex for a specific branch
func getBranchLock(branch string) *sync.RWMutex {
branchMutex.Lock()
defer branchMutex.Unlock()
if lock, exists := branchLocks[branch]; exists {
return lock
}
lock := &sync.RWMutex{}
branchLocks[branch] = lock
return lock
}
// AcquireReadLock acquires a read lock for a branch (used by syncServer)
// Returns an unlock function that must be called when done reading
func AcquireReadLock(branch string) func() {
if branch == "" {
branch = "main"
}
lock := getBranchLock(branch)
lock.RLock()
log.Printf("Acquired read lock for branch '%s'", branch)
return func() {
lock.RUnlock()
log.Printf("Released read lock for branch '%s'", branch)
}
}
// getRustInstallPath returns the installation path for a branch
func getRustInstallPath(branch string) string {
return filepath.Join(RustInstallBase, branch)
}
// setupSteamCMD downloads and extracts steamcmd (shared installation)
func setupSteamCMD() error {
// Check if steamcmd already exists
steamcmdBinary := filepath.Join(SteamCMDBase, "steamcmd.sh")
if _, err := os.Stat(steamcmdBinary); err == nil {
log.Println("SteamCMD already installed")
return nil
}
log.Println("Downloading SteamCMD...")
// Create steamcmd directory
if err := os.MkdirAll(SteamCMDBase, 0755); err != nil {
return fmt.Errorf("failed to create steamcmd directory: %w", err)
}
// Download steamcmd
tarPath := filepath.Join(RustInstallBase, "steamcmd_linux.tar.gz")
if err := downloadFile(SteamCMDURL, tarPath); err != nil {
return fmt.Errorf("failed to download steamcmd: %w", err)
}
log.Println("Extracting SteamCMD...")
// Extract steamcmd
cmd := exec.Command("tar", "-xzf", tarPath, "-C", SteamCMDBase)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to extract steamcmd: %w\nOutput: %s", err, output)
}
// Clean up tar file
os.Remove(tarPath)
log.Println("✓ SteamCMD installed")
return nil
}
// updateRustBranch runs steamcmd to install/update Rust
func updateRustBranch(branch, installPath string) error {
steamcmdBinary := filepath.Join(SteamCMDBase, "steamcmd.sh")
// Determine branch options
branchOpts := getBranchOpts(branch)
log.Printf("Running steamcmd to install Rust (branch: %s)...", branch)
// Build steamcmd command
// +force_install_dir <path> +login anonymous +app_update 258550 <branch_opts> validate +quit
cmd := exec.Command(steamcmdBinary,
"+force_install_dir", installPath,
"+login", "anonymous",
"+app_update", RustAppID)
// Add branch opts if any
if branchOpts != "" {
cmd.Args = append(cmd.Args, strings.Fields(branchOpts)...)
}
cmd.Args = append(cmd.Args, "validate", "+quit")
// Set environment to avoid terminal issues
cmd.Env = append(os.Environ(), "TERM=xterm")
// Run command with retries
maxRetries := 3
for i := 0; i < maxRetries; i++ {
log.Printf("Attempt %d/%d...", i+1, maxRetries)
output, err := cmd.CombinedOutput()
if err == nil {
log.Println("✓ Rust branch update complete")
return trackBuildID(installPath)
}
log.Printf("Attempt %d failed: %v", i+1, err)
if i < maxRetries-1 {
log.Println("Retrying...")
} else {
return fmt.Errorf("failed to update branch after %d attempts: %w\nOutput: %s", maxRetries, err, output)
}
}
return nil
}
// getBranchOpts returns steamcmd branch options based on branch name
func getBranchOpts(branch string) string {
if branch == "" || branch == "main" {
return "-beta public"
}
return fmt.Sprintf("-beta %s", branch)
}
// trackBuildID reads and stores the current build ID
func trackBuildID(installPath string) error {
manifestPath := filepath.Join(installPath, "steamapps", "appmanifest_258550.acf")
buildidPath := filepath.Join(installPath, "buildid")
// Read manifest to get build ID
data, err := os.ReadFile(manifestPath)
if err != nil {
log.Printf("Warning: Could not read manifest file: %v", err)
return nil // Not critical
}
// Extract buildid from manifest
// Format: "buildid" "12345678"
lines := strings.Split(string(data), "\n")
var buildid string
for _, line := range lines {
if strings.Contains(line, "buildid") {
parts := strings.Fields(line)
if len(parts) >= 2 {
buildid = strings.Trim(parts[1], "\"")
break
}
}
}
if buildid == "" {
log.Println("Warning: Could not extract buildid from manifest")
return nil
}
// Write buildid
if err := os.WriteFile(buildidPath, []byte(buildid), 0644); err != nil {
log.Printf("Warning: Could not write buildid: %v", err)
} else {
log.Printf("Build ID: %s", buildid)
}
return nil
}
// isRustInstalled checks if a Rust installation exists
func isRustInstalled(path string) bool {
// Check if RustDedicated binary exists
rustBinary := filepath.Join(path, "RustDedicated")
_, err := os.Stat(rustBinary)
return err == nil
}
// CheckForUpdates checks if a branch has updates available
func CheckForUpdates(branch, webhookURL string) (bool, string, error) {
installPath := getRustInstallPath(branch)
// Check if branch is installed
if !isRustInstalled(installPath) {
return false, "", nil
}
// Get current installed build ID
buildidPath := filepath.Join(installPath, "buildid")
currentBuildData, err := os.ReadFile(buildidPath)
if err != nil {
log.Printf("Warning: Could not read current buildid for %s: %v", branch, err)
return false, "", nil
}
currentBuildID := strings.TrimSpace(string(currentBuildData))
// Get latest build ID from Steam
latestBuildID, err := getLatestBuildID(branch)
if err != nil {
log.Printf("Error checking for updates for branch %s: %v", branch, err)
return false, "", err
}
// Compare build IDs
if currentBuildID != latestBuildID {
log.Printf("Update available for branch %s: %s -> %s", branch, currentBuildID, latestBuildID)
// Send notification
discord.SendInfo(webhookURL, "Rust Update Available",
fmt.Sprintf("Rust branch **%s** has an update available\n\nCurrent: **%s**\nAvailable: **%s**",
branch, currentBuildID, latestBuildID))
return true, latestBuildID, nil
}
return false, currentBuildID, nil
}
// getLatestBuildID queries Steam for the latest build ID of a branch
func getLatestBuildID(branch string) (string, error) {
steamcmdBinary := filepath.Join(SteamCMDBase, "steamcmd.sh")
// Determine branch parameter for steamcmd
branchParam := "public"
if branch != "" && branch != "main" {
branchParam = branch
}
// Run: steamcmd +login anonymous +app_info_update 1 +app_info_print 258550 +quit
cmd := exec.Command(steamcmdBinary,
"+login", "anonymous",
"+app_info_update", "1",
"+app_info_print", RustAppID,
"+quit")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to run steamcmd: %w", err)
}
// Parse output to find buildid for the specific branch
buildID, err := parseBuildIDFromAppInfo(string(output), branchParam)
if err != nil {
return "", fmt.Errorf("failed to parse buildid: %w", err)
}
return buildID, nil
}
// parseBuildIDFromAppInfo extracts the build ID for a specific branch from app_info_print output
func parseBuildIDFromAppInfo(output, branch string) (string, error) {
lines := strings.Split(output, "\n")
// Look for the branch section and extract buildid
// Format is nested like:
// "branches"
// {
// "public"
// {
// "buildid" "12345678"
// }
// }
inBranches := false
inTargetBranch := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// Find branches section
if strings.Contains(trimmed, `"branches"`) {
inBranches = true
continue
}
// Find our specific branch
if inBranches && strings.Contains(trimmed, fmt.Sprintf(`"%s"`, branch)) {
inTargetBranch = true
continue
}
// Extract buildid from the branch section
if inTargetBranch && strings.Contains(trimmed, `"buildid"`) {
// Parse: "buildid" "12345678"
parts := strings.Fields(trimmed)
if len(parts) >= 2 {
buildID := strings.Trim(parts[1], `"`)
return buildID, nil
}
}
// Exit branch section when we hit a closing brace at the same level
if inTargetBranch && trimmed == "}" {
// Check if next non-empty line is also a brace (end of branches section)
for j := i + 1; j < len(lines); j++ {
nextTrimmed := strings.TrimSpace(lines[j])
if nextTrimmed != "" {
if nextTrimmed == "}" {
inBranches = false
}
break
}
}
inTargetBranch = false
}
}
return "", fmt.Errorf("buildid not found for branch %s", branch)
}
// downloadFile downloads a file from a URL
func downloadFile(url, filepath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
package version
// These variables are set at build time via ldflags
var (
Version = "dev" // Injected via -ldflags "-X internal/version.Version=v1.2.3"
GitCommit = "unknown" // Injected via -ldflags "-X internal/version.GitCommit=abc123"
BuildDate = "unknown" // Injected via -ldflags "-X internal/version.BuildDate=2024-01-01"
)
// GetVersion returns the full version string
func GetVersion() string {
if Version == "dev" {
return "dev (commit: " + GitCommit + ")"
}
return Version
}
// GetFullVersion returns the version with all metadata
func GetFullVersion() string {
return "wipe-cli " + GetVersion() + " (built: " + BuildDate + ")"
}
package test
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
// CalendarEvent represents a calendar event
type CalendarEvent struct {
ID string
Summary string
StartTime time.Time
}
// CalendarServer is a test HTTP server that serves ICS calendar files
// It supports multiple calendars (one per server) at paths like /server-name/basic.ics
type CalendarServer struct {
server *httptest.Server
// events is a map of server name -> event ID -> event
events map[string]map[string]CalendarEvent
mu sync.RWMutex
t *testing.T
}
// NewCalendarServer creates a new test calendar server
func NewCalendarServer(t *testing.T) *CalendarServer {
cs := &CalendarServer{
events: make(map[string]map[string]CalendarEvent),
t: t,
}
mux := http.NewServeMux()
// Endpoint to get calendar for a specific server: /server-name/basic.ics
mux.HandleFunc("/", cs.handleCalendar)
// Endpoint to add events (for test control)
// POST /add-event?server=X&id=Y&summary=Z&start=W
mux.HandleFunc("/add-event", cs.handleAddEvent)
// Endpoint to remove events (for test control)
// POST /remove-event?server=X&id=Y
mux.HandleFunc("/remove-event", cs.handleRemoveEvent)
// Endpoint to clear all events
// POST /clear-events or /clear-events?server=X
mux.HandleFunc("/clear-events", cs.handleClearEvents)
// Endpoint to list events
// GET /list-events or /list-events?server=X
mux.HandleFunc("/list-events", cs.handleListEvents)
// Create unstarted server so we can set a fixed port
cs.server = httptest.NewUnstartedServer(mux)
// Use fixed port 45975
listener, err := net.Listen("tcp", "127.0.0.1:45975")
if err != nil {
t.Fatalf("Failed to listen on port 45975: %v", err)
}
cs.server.Listener = listener
cs.server.Start()
return cs
}
// NewRemoteCalendarServer creates a CalendarServer wrapper that connects to an existing calendar server
func NewRemoteCalendarServer(t *testing.T, baseURL string) *CalendarServer {
// Remove any trailing /server-name/basic.ics to get base URL
// Just use the protocol://host:port part
if idx := strings.Index(baseURL, "//"); idx != -1 {
rest := baseURL[idx+2:]
if slashIdx := strings.Index(rest, "/"); slashIdx != -1 {
baseURL = baseURL[:idx+2+slashIdx]
}
}
// Create a mock server struct that uses HTTP endpoints instead of in-memory
cs := &CalendarServer{
events: nil, // Not used for remote server
t: t,
server: &httptest.Server{
URL: baseURL,
},
}
return cs
}
// GetServerURL returns the calendar URL for a specific server
func (cs *CalendarServer) GetServerURL(serverName string) string {
return fmt.Sprintf("%s/%s/basic.ics", cs.server.URL, serverName)
}
// BaseURL returns the base server URL
func (cs *CalendarServer) BaseURL() string {
return cs.server.URL
}
// Close stops the calendar server (no-op for remote servers)
func (cs *CalendarServer) Close() {
// Don't close remote servers
if cs.events == nil {
return
}
cs.server.Close()
}
// AddEventForServer adds an event to a specific server's calendar
func (cs *CalendarServer) AddEventForServer(serverName, id, summary string, startTime time.Time) {
// If this is a remote server, use HTTP endpoint
if cs.events == nil {
reqURL := fmt.Sprintf("%s/add-event?server=%s&id=%s&summary=%s&start=%s",
cs.server.URL,
url.QueryEscape(serverName),
url.QueryEscape(id),
url.QueryEscape(summary),
url.QueryEscape(startTime.Format(time.RFC3339)))
resp, err := http.Post(reqURL, "", nil)
if err != nil {
cs.t.Fatalf("Failed to add event to remote server: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
cs.t.Fatalf("Failed to add event to remote server, status: %d", resp.StatusCode)
}
cs.t.Logf("Event added to remote server %s: %s - %s at %s", serverName, id, summary, startTime.Format(time.RFC3339))
return
}
// Local server - direct manipulation
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.events[serverName] == nil {
cs.events[serverName] = make(map[string]CalendarEvent)
}
cs.events[serverName][id] = CalendarEvent{
ID: id,
Summary: summary,
StartTime: startTime,
}
cs.t.Logf("Event added for %s: %s - %s at %s", serverName, id, summary, startTime.Format(time.RFC3339))
}
// RemoveEventForServer removes an event from a specific server's calendar
func (cs *CalendarServer) RemoveEventForServer(serverName, id string) {
// If this is a remote server, use HTTP endpoint
if cs.events == nil {
reqURL := fmt.Sprintf("%s/remove-event?server=%s&id=%s",
cs.server.URL,
url.QueryEscape(serverName),
url.QueryEscape(id))
resp, err := http.Post(reqURL, "", nil)
if err != nil {
cs.t.Fatalf("Failed to remove event from remote server: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
cs.t.Fatalf("Failed to remove event from remote server, status: %d", resp.StatusCode)
}
cs.t.Logf("Event removed from remote server %s: %s", serverName, id)
return
}
// Local server - direct manipulation
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.events[serverName] != nil {
delete(cs.events[serverName], id)
}
cs.t.Logf("Event removed from %s: %s", serverName, id)
}
// ClearEventsForServer removes all events from a specific server's calendar
func (cs *CalendarServer) ClearEventsForServer(serverName string) {
// If this is a remote server, use HTTP endpoint
if cs.events == nil {
reqURL := fmt.Sprintf("%s/clear-events?server=%s", cs.server.URL, url.QueryEscape(serverName))
resp, err := http.Post(reqURL, "", nil)
if err != nil {
cs.t.Fatalf("Failed to clear events on remote server: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
cs.t.Fatalf("Failed to clear events on remote server, status: %d", resp.StatusCode)
}
cs.t.Logf("All events cleared on remote server for %s", serverName)
return
}
// Local server - direct manipulation
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.events[serverName] != nil {
cs.events[serverName] = make(map[string]CalendarEvent)
}
cs.t.Logf("All events cleared for %s", serverName)
}
// ClearAllEvents removes all events from all servers
func (cs *CalendarServer) ClearAllEvents() {
// If this is a remote server, use HTTP endpoint
if cs.events == nil {
reqURL := fmt.Sprintf("%s/clear-events", cs.server.URL)
resp, err := http.Post(reqURL, "", nil)
if err != nil {
cs.t.Fatalf("Failed to clear events on remote server: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
cs.t.Fatalf("Failed to clear events on remote server, status: %d", resp.StatusCode)
}
cs.t.Log("All events cleared on remote server")
return
}
// Local server - direct manipulation
cs.mu.Lock()
defer cs.mu.Unlock()
cs.events = make(map[string]map[string]CalendarEvent)
cs.t.Log("All events cleared")
}
// handleCalendar serves the ICS calendar file for a specific server
func (cs *CalendarServer) handleCalendar(w http.ResponseWriter, r *http.Request) {
// Extract server name from path: /server-name/basic.ics
path := strings.Trim(r.URL.Path, "/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "basic.ics" {
http.Error(w, "Not found - expected /{server-name}/basic.ics", http.StatusNotFound)
return
}
serverName := parts[0]
cs.mu.RLock()
serverEvents := cs.events[serverName]
eventCount := len(serverEvents)
cs.mu.RUnlock()
cs.t.Logf("Calendar requested for %s (%d event(s))", serverName, eventCount)
ics := cs.generateICS(serverName, serverEvents)
w.Header().Set("Content-Type", "text/calendar")
w.Write([]byte(ics))
}
// handleAddEvent handles adding events via HTTP POST
func (cs *CalendarServer) handleAddEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
serverName := r.URL.Query().Get("server")
eventID := r.URL.Query().Get("id")
summary := r.URL.Query().Get("summary")
startTime := r.URL.Query().Get("start")
if serverName == "" || eventID == "" || summary == "" || startTime == "" {
http.Error(w, "Missing parameters (server, id, summary, start required)", http.StatusBadRequest)
return
}
// Parse start time (RFC3339 or iCal format)
var parsedTime time.Time
var err error
// Try RFC3339 first
parsedTime, err = time.Parse(time.RFC3339, startTime)
if err != nil {
// Try iCal format (20060102T150405Z)
parsedTime, err = time.Parse("20060102T150405Z", startTime)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid time format: %v", err), http.StatusBadRequest)
return
}
}
cs.mu.Lock()
if cs.events[serverName] == nil {
cs.events[serverName] = make(map[string]CalendarEvent)
}
cs.events[serverName][eventID] = CalendarEvent{
ID: eventID,
Summary: summary,
StartTime: parsedTime,
}
cs.mu.Unlock()
cs.t.Logf("Event added for %s: %s - %s at %s", serverName, eventID, summary, parsedTime.Format(time.RFC3339))
fmt.Fprintf(w, "Event added for %s: %s\n", serverName, eventID)
}
// handleRemoveEvent handles removing events via HTTP POST
func (cs *CalendarServer) handleRemoveEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
serverName := r.URL.Query().Get("server")
eventID := r.URL.Query().Get("id")
if serverName == "" || eventID == "" {
http.Error(w, "Missing parameters (server, id required)", http.StatusBadRequest)
return
}
cs.mu.Lock()
if cs.events[serverName] != nil {
delete(cs.events[serverName], eventID)
}
cs.mu.Unlock()
cs.t.Logf("Event removed from %s: %s", serverName, eventID)
fmt.Fprintf(w, "Event removed from %s: %s\n", serverName, eventID)
}
// handleClearEvents handles clearing all events via HTTP POST
func (cs *CalendarServer) handleClearEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
serverName := r.URL.Query().Get("server")
cs.mu.Lock()
if serverName != "" {
// Clear events for specific server
if cs.events[serverName] != nil {
cs.events[serverName] = make(map[string]CalendarEvent)
}
cs.mu.Unlock()
cs.t.Logf("All events cleared for %s", serverName)
fmt.Fprintf(w, "All events cleared for %s\n", serverName)
} else {
// Clear all events
cs.events = make(map[string]map[string]CalendarEvent)
cs.mu.Unlock()
cs.t.Log("All events cleared")
fmt.Fprintln(w, "All events cleared")
}
}
// handleListEvents lists all events as JSON
func (cs *CalendarServer) handleListEvents(w http.ResponseWriter, r *http.Request) {
serverName := r.URL.Query().Get("server")
cs.mu.RLock()
defer cs.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
if serverName != "" {
// List events for specific server
serverEvents := cs.events[serverName]
fmt.Fprintf(w, "{\n \"server\": %q,\n \"count\": %d,\n \"events\": [\n", serverName, len(serverEvents))
first := true
for _, event := range serverEvents {
if !first {
fmt.Fprint(w, ",\n")
}
first = false
fmt.Fprintf(w, " {\n \"id\": %q,\n \"summary\": %q,\n \"start_time\": %q\n }",
event.ID, event.Summary, event.StartTime.Format(time.RFC3339))
}
fmt.Fprint(w, "\n ]\n}\n")
} else {
// List all events from all servers
totalCount := 0
for _, serverEvents := range cs.events {
totalCount += len(serverEvents)
}
fmt.Fprintf(w, "{\n \"total_count\": %d,\n \"servers\": [\n", totalCount)
firstServer := true
for server, serverEvents := range cs.events {
if !firstServer {
fmt.Fprint(w, ",\n")
}
firstServer = false
fmt.Fprintf(w, " {\n \"server\": %q,\n \"count\": %d,\n \"events\": [\n", server, len(serverEvents))
firstEvent := true
for _, event := range serverEvents {
if !firstEvent {
fmt.Fprint(w, ",\n")
}
firstEvent = false
fmt.Fprintf(w, " {\n \"id\": %q,\n \"summary\": %q,\n \"start_time\": %q\n }",
event.ID, event.Summary, event.StartTime.Format(time.RFC3339))
}
fmt.Fprint(w, "\n ]\n }")
}
fmt.Fprint(w, "\n ]\n}\n")
}
}
// generateICS creates an ICS calendar file from events for a specific server
func (cs *CalendarServer) generateICS(serverName string, events map[string]CalendarEvent) string {
ics := `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//wipe-cli//E2E Test//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:` + serverName + `
X-WR-TIMEZONE:UTC
`
for _, event := range events {
startTime := event.StartTime.UTC().Format("20060102T150405Z")
ics += fmt.Sprintf(`BEGIN:VEVENT
UID:%s
SUMMARY:%s
DTSTART:%s
DTEND:%s
END:VEVENT
`, event.ID, event.Summary, startTime, startTime)
}
ics += "END:VCALENDAR\n"
return ics
}