cron.go (4378B)
1 package jci 2 3 import ( 4 "bufio" 5 "crypto/sha1" 6 "encoding/hex" 7 "errors" 8 "fmt" 9 "os" 10 "os/exec" 11 "path/filepath" 12 "strings" 13 ) 14 15 func Cron(args []string) error { 16 if len(args) == 0 { 17 return errors.New("usage: git jci cron <ls|sync>") 18 } 19 20 repoRoot, err := GetRepoRoot() 21 if err != nil { 22 return err 23 } 24 25 switch args[0] { 26 case "ls", "list": 27 return cronList(repoRoot) 28 case "sync": 29 return cronSync(repoRoot) 30 default: 31 return fmt.Errorf("unknown cron subcommand: %s", args[0]) 32 } 33 } 34 35 func cronList(repoRoot string) error { 36 entries, err := LoadCronEntries(repoRoot) 37 if err != nil { 38 return err 39 } 40 41 if len(entries) == 0 { 42 fmt.Println("No cron jobs defined. Create .jci/crontab to add jobs.") 43 return nil 44 } 45 46 fmt.Printf("%-10s %-17s %-10s %s\n", "ID", "SCHEDULE", "TYPE", "COMMAND") 47 for _, entry := range entries { 48 job := newCronJob(entry, repoRoot) 49 fmt.Printf("%-10s %-17s %-10s %s\n", job.ID[:8], job.Schedule, job.Type, job.Command) 50 if job.Type == CronJobBinary { 51 if _, err := os.Stat(job.BinaryPath); os.IsNotExist(err) { 52 fmt.Printf(" warning: binary %s does not exist\n", job.BinaryPath) 53 } 54 } 55 } 56 return nil 57 } 58 59 func cronSync(repoRoot string) error { 60 entries, err := LoadCronEntries(repoRoot) 61 if err != nil { 62 return err 63 } 64 65 if len(entries) == 0 { 66 return errors.New("no cron jobs defined in .jci/crontab") 67 } 68 69 var jobs []CronJob 70 for _, entry := range entries { 71 jobs = append(jobs, newCronJob(entry, repoRoot)) 72 } 73 74 block, err := buildCronBlock(repoRoot, jobs) 75 if err != nil { 76 return err 77 } 78 79 existing, err := readCrontab() 80 if err != nil { 81 return err 82 } 83 84 updated := applyCronBlock(existing, block, repoRoot) 85 if err := installCrontab(updated); err != nil { 86 return err 87 } 88 89 fmt.Printf("Synced %d cron job(s).\n", len(jobs)) 90 return nil 91 } 92 93 func newCronJob(entry CronEntry, repoRoot string) CronJob { 94 jobType, binPath, binArgs := classifyCronCommand(entry.Command, repoRoot) 95 id := cronJobID(entry.Schedule, entry.Command) 96 logPath := filepath.Join(repoRoot, ".jci", fmt.Sprintf("cron-%s.log", id[:8])) 97 return CronJob{ 98 ID: id, 99 Schedule: entry.Schedule, 100 Command: entry.Command, 101 Type: jobType, 102 BinaryPath: binPath, 103 BinaryArgs: binArgs, 104 Line: entry.Line, 105 CronLog: logPath, 106 } 107 } 108 109 func buildCronBlock(repoRoot string, jobs []CronJob) (string, error) { 110 if err := os.MkdirAll(filepath.Join(repoRoot, ".jci"), 0755); err != nil { 111 return "", fmt.Errorf("failed to create .jci directory: %w", err) 112 } 113 114 blockID := cronBlockMarker(repoRoot) 115 var lines []string 116 lines = append(lines, fmt.Sprintf("# BEGIN %s", blockID)) 117 118 for _, job := range jobs { 119 line := fmt.Sprintf("%s %s", job.Schedule, job.shellCommand(repoRoot)) 120 lines = append(lines, line) 121 } 122 123 lines = append(lines, fmt.Sprintf("# END %s", blockID)) 124 return strings.Join(lines, "\n"), nil 125 } 126 127 func cronBlockMarker(repoRoot string) string { 128 hash := sha1.Sum([]byte(repoRoot)) 129 return fmt.Sprintf("git-jci %s %s", repoRoot, hex.EncodeToString(hash[:8])) 130 } 131 132 func readCrontab() (string, error) { 133 if _, err := exec.LookPath("crontab"); err != nil { 134 return "", fmt.Errorf("crontab command not found: %w", err) 135 } 136 137 cmd := exec.Command("crontab", "-l") 138 out, err := cmd.CombinedOutput() 139 if err != nil { 140 if strings.Contains(string(out), "no crontab for") { 141 return "", nil 142 } 143 return "", fmt.Errorf("crontab -l: %v", err) 144 } 145 return string(out), nil 146 } 147 148 func applyCronBlock(existing, block, repoRoot string) string { 149 begin := fmt.Sprintf("# BEGIN %s", cronBlockMarker(repoRoot)) 150 end := fmt.Sprintf("# END %s", cronBlockMarker(repoRoot)) 151 152 var result []string 153 scanner := bufio.NewScanner(strings.NewReader(existing)) 154 skip := false 155 for scanner.Scan() { 156 line := scanner.Text() 157 trimmed := strings.TrimSpace(line) 158 if trimmed == begin { 159 skip = true 160 continue 161 } 162 if trimmed == end { 163 skip = false 164 continue 165 } 166 if !skip { 167 result = append(result, line) 168 } 169 } 170 171 if len(result) != 0 && strings.TrimSpace(result[len(result)-1]) != "" { 172 result = append(result, "") 173 } 174 result = append(result, block) 175 return strings.Join(result, "\n") + "\n" 176 } 177 178 func installCrontab(content string) error { 179 cmd := exec.Command("crontab", "-") 180 cmd.Stdin = strings.NewReader(content) 181 if out, err := cmd.CombinedOutput(); err != nil { 182 return fmt.Errorf("failed to install crontab: %v (%s)", err, string(out)) 183 } 184 return nil 185 }