Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README

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 }