mirror of
https://github.com/replicatedhq/troubleshoot.git
synced 2026-04-15 07:16:34 +00:00
* created roadmap and yaml claude agent * Update roadmap.md * chore(deps): bump sigstore/cosign-installer from 3.9.2 to 3.10.0 (#1857) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.9.2 to 3.10.0. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/v3.9.2...v3.10.0) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 3.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the security group with 2 updates (#1858) Bumps the security group with 2 updates: [github.com/vmware-tanzu/velero](https://github.com/vmware-tanzu/velero) and [helm.sh/helm/v3](https://github.com/helm/helm). Updates `github.com/vmware-tanzu/velero` from 1.16.2 to 1.17.0 - [Release notes](https://github.com/vmware-tanzu/velero/releases) - [Changelog](https://github.com/vmware-tanzu/velero/blob/main/CHANGELOG.md) - [Commits](https://github.com/vmware-tanzu/velero/compare/v1.16.2...v1.17.0) Updates `helm.sh/helm/v3` from 3.18.6 to 3.19.0 - [Release notes](https://github.com/helm/helm/releases) - [Commits](https://github.com/helm/helm/compare/v3.18.6...v3.19.0) --- updated-dependencies: - dependency-name: github.com/vmware-tanzu/velero dependency-version: 1.17.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: security - dependency-name: helm.sh/helm/v3 dependency-version: 3.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: security ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump helm.sh/helm/v3 from 3.18.6 to 3.19.0 in /examples/sdk/helm-template in the security group (#1859) chore(deps): bump helm.sh/helm/v3 Bumps the security group in /examples/sdk/helm-template with 1 update: [helm.sh/helm/v3](https://github.com/helm/helm). Updates `helm.sh/helm/v3` from 3.18.6 to 3.19.0 - [Release notes](https://github.com/helm/helm/releases) - [Commits](https://github.com/helm/helm/compare/v3.18.6...v3.19.0) --- updated-dependencies: - dependency-name: helm.sh/helm/v3 dependency-version: 3.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: security ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add cron job support bundle scheduler Complete implementation with K8s integration: - pkg/schedule/job.go: Job management and persistence - pkg/schedule/daemon.go: Real-time scheduler daemon - pkg/schedule/cli.go: CLI commands (create, list, delete, daemon) - pkg/schedule/schedule_test.go: Comprehensive unit tests - cmd/troubleshoot/cli/root.go: CLI integration * fixing bugbot * Fix all bugbot errors: auto-update stability, job cooldown timing, and daemon execution * Deleting Agent * removed unused flags * fixing auto-upload * fixing markdown files * namespace not required flag for auto collectors to work * loosened cron job validation * writes logs to logfile * fix: resolve autoFromEnv variable scoping issue for CI - Ensure autoFromEnv variable and its usage are in correct scope - Fix build errors: declared and not used / undefined variable - All functionality preserved and tested locally - Force add to override gitignore --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Noah Campbell <noah.edward.campbell@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
343 lines
8.5 KiB
Go
343 lines
8.5 KiB
Go
package schedule
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Daemon runs scheduled jobs
|
|
type Daemon struct {
|
|
manager *Manager
|
|
running bool
|
|
jobMutex sync.Mutex
|
|
runningJobs map[string]bool // Track running jobs to prevent concurrent execution
|
|
logger *log.Logger
|
|
logFile *os.File
|
|
}
|
|
|
|
// NewDaemon creates a new daemon
|
|
func NewDaemon() (*Daemon, error) {
|
|
manager, err := NewManager()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create job manager: %w", err)
|
|
}
|
|
|
|
// Setup persistent logging
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user home directory: %w", err)
|
|
}
|
|
|
|
logDir := filepath.Join(homeDir, ".troubleshoot")
|
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create log directory %s: %w", logDir, err)
|
|
}
|
|
|
|
logPath := filepath.Join(logDir, "scheduler.log")
|
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open log file %s: %w", logPath, err)
|
|
}
|
|
|
|
logger := log.New(logFile, "", log.LstdFlags)
|
|
|
|
return &Daemon{
|
|
manager: manager,
|
|
running: false,
|
|
runningJobs: make(map[string]bool),
|
|
logger: logger,
|
|
logFile: logFile,
|
|
}, nil
|
|
}
|
|
|
|
// Start starts the daemon to monitor and execute jobs
|
|
func (d *Daemon) Start() error {
|
|
d.running = true
|
|
|
|
// Setup signal handling
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
// Ensure signal handling is cleaned up and close log file
|
|
defer func() {
|
|
signal.Stop(sigChan)
|
|
if d.logFile != nil {
|
|
d.logFile.Close()
|
|
}
|
|
}()
|
|
|
|
d.logInfo("Scheduler daemon started")
|
|
d.logInfo("Monitoring scheduled jobs every minute...")
|
|
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for d.running {
|
|
select {
|
|
case <-ticker.C:
|
|
d.checkAndExecuteJobs()
|
|
case sig := <-sigChan:
|
|
d.logInfo(fmt.Sprintf("Received signal %v, shutting down...", sig))
|
|
d.running = false
|
|
}
|
|
}
|
|
|
|
d.logInfo("Scheduler daemon stopped")
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the daemon
|
|
func (d *Daemon) Stop() {
|
|
d.running = false
|
|
}
|
|
|
|
// checkAndExecuteJobs checks for jobs that should run now
|
|
func (d *Daemon) checkAndExecuteJobs() {
|
|
jobs, err := d.manager.ListJobs()
|
|
if err != nil {
|
|
d.logError(fmt.Sprintf("Error loading jobs: %v", err))
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, job := range jobs {
|
|
if job == nil {
|
|
continue // Skip nil jobs
|
|
}
|
|
|
|
if job.Enabled && d.shouldJobRun(job, now) {
|
|
// Check if job is already running to prevent concurrent execution
|
|
d.jobMutex.Lock()
|
|
if d.runningJobs[job.ID] {
|
|
d.jobMutex.Unlock()
|
|
continue // Skip if already running
|
|
}
|
|
d.runningJobs[job.ID] = true
|
|
d.jobMutex.Unlock()
|
|
|
|
go d.executeJob(job)
|
|
}
|
|
}
|
|
}
|
|
|
|
// shouldJobRun checks if a job should run based on its schedule
|
|
func (d *Daemon) shouldJobRun(job *Job, now time.Time) bool {
|
|
if job == nil {
|
|
return false
|
|
}
|
|
|
|
// Prevent running multiple times in the same minute (avoid duplicates)
|
|
// Use 90-second cooldown to ensure we don't run more than once per minute
|
|
// even with slight timing variations in the daemon's check cycle
|
|
if !job.LastRun.IsZero() && now.Sub(job.LastRun) < 90*time.Second {
|
|
return false
|
|
}
|
|
|
|
// Parse cron schedule (minute hour day-of-month month day-of-week)
|
|
parts := strings.Fields(job.Schedule)
|
|
if len(parts) != 5 {
|
|
return false
|
|
}
|
|
|
|
minute := parts[0]
|
|
hour := parts[1]
|
|
dayOfMonth := parts[2]
|
|
month := parts[3]
|
|
dayOfWeek := parts[4]
|
|
|
|
// Check if current time matches all cron fields
|
|
if !matchesCronField(minute, now.Minute()) {
|
|
return false
|
|
}
|
|
if !matchesCronField(hour, now.Hour()) {
|
|
return false
|
|
}
|
|
if !matchesCronField(dayOfMonth, now.Day()) {
|
|
return false
|
|
}
|
|
if !matchesCronField(month, int(now.Month())) {
|
|
return false
|
|
}
|
|
// Day of week: Sunday = 0, Monday = 1, etc.
|
|
if !matchesCronField(dayOfWeek, int(now.Weekday())) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// matchesCronField checks if a cron field matches the current time value
|
|
func matchesCronField(field string, currentValue int) bool {
|
|
if field == "*" {
|
|
return true
|
|
}
|
|
|
|
// Handle */N syntax (e.g., */2 for every 2 minutes)
|
|
if strings.HasPrefix(field, "*/") {
|
|
intervalStr := strings.TrimPrefix(field, "*/")
|
|
if interval, err := strconv.Atoi(intervalStr); err == nil && interval > 0 {
|
|
return currentValue%interval == 0
|
|
}
|
|
return false // Invalid interval format
|
|
}
|
|
|
|
// Handle comma-separated lists (e.g., "1,15,30")
|
|
values := strings.Split(field, ",")
|
|
for _, val := range values {
|
|
val = strings.TrimSpace(val)
|
|
if fieldValue, err := strconv.Atoi(val); err == nil {
|
|
if currentValue == fieldValue {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// findSupportBundleBinary finds the support-bundle binary path
|
|
func findSupportBundleBinary() (string, error) {
|
|
// First try current directory
|
|
if _, err := os.Stat("./support-bundle"); err == nil {
|
|
abs, _ := filepath.Abs("./support-bundle")
|
|
return abs, nil
|
|
}
|
|
|
|
// Try relative to current binary location
|
|
if execPath, err := os.Executable(); err == nil {
|
|
supportBundlePath := filepath.Join(filepath.Dir(execPath), "support-bundle")
|
|
if _, err := os.Stat(supportBundlePath); err == nil {
|
|
return supportBundlePath, nil
|
|
}
|
|
}
|
|
|
|
// Try PATH
|
|
if path, err := exec.LookPath("support-bundle"); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("support-bundle binary not found")
|
|
}
|
|
|
|
// executeJob runs a support bundle collection
|
|
func (d *Daemon) executeJob(job *Job) {
|
|
if job == nil {
|
|
return
|
|
}
|
|
|
|
// Ensure we mark the job as not running when done
|
|
defer func() {
|
|
d.jobMutex.Lock()
|
|
delete(d.runningJobs, job.ID)
|
|
d.jobMutex.Unlock()
|
|
}()
|
|
|
|
d.logInfo(fmt.Sprintf("Executing job: %s", job.Name))
|
|
|
|
// Build command arguments (no subcommand needed - binary IS support-bundle)
|
|
args := []string{}
|
|
if job.Namespace != "" {
|
|
args = append(args, "--namespace", job.Namespace)
|
|
}
|
|
if job.Auto {
|
|
args = append(args, "--auto")
|
|
}
|
|
if job.Upload != "" {
|
|
args = append(args, "--auto-upload")
|
|
// Add license and app flags if available in the future
|
|
// if job.LicenseID != "" {
|
|
// args = append(args, "--license-id", job.LicenseID)
|
|
// }
|
|
// if job.AppSlug != "" {
|
|
// args = append(args, "--app-slug", job.AppSlug)
|
|
// }
|
|
}
|
|
|
|
// Disable auto-update for scheduled jobs
|
|
args = append(args, "--auto-update=false")
|
|
|
|
// Find support-bundle binary
|
|
supportBundleBinary, err := findSupportBundleBinary()
|
|
if err != nil {
|
|
d.logError(fmt.Sprintf("Job failed: %s - cannot find support-bundle binary: %v", job.Name, err))
|
|
return
|
|
}
|
|
|
|
// Execute support-bundle command directly with output capture
|
|
cmd := exec.Command(supportBundleBinary, args...)
|
|
|
|
// Capture both stdout and stderr
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
d.logError(fmt.Sprintf("Job failed: %s - %v", job.Name, err))
|
|
if len(output) > 0 {
|
|
d.logError(fmt.Sprintf("Command output for %s:\n%s", job.Name, string(output)))
|
|
}
|
|
return
|
|
}
|
|
|
|
d.logInfo(fmt.Sprintf("Job completed: %s", job.Name))
|
|
|
|
// Log key information but skip verbose JSON output
|
|
if len(output) > 0 {
|
|
outputStr := string(output)
|
|
|
|
// Extract and log only the important parts
|
|
if strings.Contains(outputStr, "Successfully uploaded support bundle") {
|
|
d.logInfo(fmt.Sprintf("Upload successful for job: %s", job.Name))
|
|
}
|
|
if strings.Contains(outputStr, "Auto-upload failed:") {
|
|
// Log upload failures in detail
|
|
lines := strings.Split(outputStr, "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "Auto-upload failed:") {
|
|
d.logError(fmt.Sprintf("Upload failed for job %s: %s", job.Name, strings.TrimSpace(line)))
|
|
}
|
|
}
|
|
}
|
|
if strings.Contains(outputStr, "archivePath") {
|
|
// Extract just the archive name
|
|
lines := strings.Split(outputStr, "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "archivePath") {
|
|
d.logInfo(fmt.Sprintf("Archive created for job %s: %s", job.Name, strings.TrimSpace(line)))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update job stats only on success
|
|
job.RunCount++
|
|
job.LastRun = time.Now()
|
|
if err := d.manager.saveJob(job); err != nil {
|
|
d.logError(fmt.Sprintf("Warning: Failed to save job statistics for %s: %v", job.Name, err))
|
|
}
|
|
}
|
|
|
|
// logInfo logs an info message to both console and file
|
|
func (d *Daemon) logInfo(message string) {
|
|
fmt.Printf("✓ %s\n", message)
|
|
if d.logger != nil {
|
|
d.logger.Printf("INFO: %s", message)
|
|
}
|
|
}
|
|
|
|
// logError logs an error message to both console and file
|
|
func (d *Daemon) logError(message string) {
|
|
fmt.Printf("❌ %s\n", message)
|
|
if d.logger != nil {
|
|
d.logger.Printf("ERROR: %s", message)
|
|
}
|
|
}
|