Skip to content

Commit 447b1e0

Browse files
committed
Add CRONITOR_USERS param to specify which user crontabs to load
1 parent acada3f commit 447b1e0

File tree

8 files changed

+184
-34
lines changed

8 files changed

+184
-34
lines changed

cmd/configure.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type ConfigFile struct {
2222
DashPassword string `json:"CRONITOR_DASH_PASS"`
2323
AllowedIPs string `json:"CRONITOR_ALLOWED_IPS"`
2424
CorsAllowedOrigins string `json:"CRONITOR_CORS_ALLOWED_ORIGINS"`
25+
Users string `json:"CRONITOR_USERS"`
2526
}
2627

2728
// configureCmd represents the configure command
@@ -46,6 +47,7 @@ Environment variables that are read:
4647
CRONITOR_HOSTNAME
4748
CRONITOR_LOG
4849
CRONITOR_PING_API_KEY
50+
CRONITOR_USERS
4951
5052
Example setting your API Key:
5153
$ cronitor configure --api-key 4319e94e890a013dbaca57c2df2ff60c2
@@ -65,6 +67,7 @@ Example setting common exclude text for use with 'cronitor discover':
6567
configData.DashPassword = viper.GetString(varDashPassword)
6668
configData.AllowedIPs = viper.GetString(varAllowedIPs)
6769
configData.CorsAllowedOrigins = viper.GetString("CRONITOR_CORS_ALLOWED_ORIGINS")
70+
configData.Users = viper.GetString(varUsers)
6871

6972
fmt.Println("\nConfiguration File:")
7073
fmt.Println(configFilePath())
@@ -127,6 +130,13 @@ Example setting common exclude text for use with 'cronitor discover':
127130
fmt.Println(configData.AllowedIPs)
128131
}
129132

133+
fmt.Println("\nUsers:")
134+
if configData.Users == "" {
135+
fmt.Println("Current user only")
136+
} else {
137+
fmt.Println(configData.Users)
138+
}
139+
130140
if verbose {
131141
fmt.Println("\nEnviornment Variables:")
132142
for _, pair := range os.Environ() {
@@ -170,6 +180,7 @@ func init() {
170180
configureCmd.Flags().String("ping-api-key", "", "Your Cronitor Ping API key")
171181
configureCmd.Flags().String("log", "", "Path to debug log file")
172182
configureCmd.Flags().String("env", "", "Environment name (e.g. staging, production)")
183+
configureCmd.Flags().String("users", "", "Comma-separated list of users whose crontabs to include")
173184

174185
viper.BindPFlag(varExcludeText, configureCmd.Flags().Lookup("exclude-from-name"))
175186
viper.BindPFlag(varDashUsername, configureCmd.Flags().Lookup("dash-username"))
@@ -178,4 +189,5 @@ func init() {
178189
viper.BindPFlag(varPingApiKey, configureCmd.Flags().Lookup("ping-api-key"))
179190
viper.BindPFlag(varLog, configureCmd.Flags().Lookup("log"))
180191
viper.BindPFlag(varEnv, configureCmd.Flags().Lookup("env"))
192+
viper.BindPFlag(varUsers, configureCmd.Flags().Lookup("users"))
181193
}

cmd/dash.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,7 @@ func handleSettings(w http.ResponseWriter, r *http.Request) {
11191119
"CRONITOR_DASH_PASS": os.Getenv(varDashPassword) != "",
11201120
"CRONITOR_ALLOWED_IPS": os.Getenv(varAllowedIPs) != "",
11211121
"CRONITOR_CORS_ALLOWED_ORIGINS": os.Getenv("CRONITOR_CORS_ALLOWED_ORIGINS") != "",
1122+
"CRONITOR_USERS": os.Getenv(varUsers) != "",
11221123
},
11231124
OS: runtime.GOOS,
11241125
SafeMode: isSafeModeEnabled,
@@ -1180,6 +1181,9 @@ func handleSettings(w http.ResponseWriter, r *http.Request) {
11801181
if !viper.IsSet(varDashPassword) {
11811182
viper.Set(varDashPassword, configData.DashPassword)
11821183
}
1184+
if !viper.IsSet(varUsers) {
1185+
viper.Set(varUsers, configData.Users)
1186+
}
11831187

11841188
// Handle AllowedIPs specially - always update if not overridden by environment variable
11851189
if os.Getenv(varAllowedIPs) == "" {
@@ -1283,7 +1287,7 @@ func handleJobs(w http.ResponseWriter, r *http.Request) {
12831287

12841288
func handleGetJobs(w http.ResponseWriter, r *http.Request) {
12851289
// Parse crontabs
1286-
crontabs, err := lib.GetAllCrontabs()
1290+
crontabs, err := lib.GetAllCrontabs(parseUsers())
12871291
if err != nil {
12881292
http.Error(w, err.Error(), http.StatusInternalServerError)
12891293
return
@@ -1420,7 +1424,7 @@ func handleGetMonitors(w http.ResponseWriter, r *http.Request) {
14201424
// Sync monitor names with crontab job names
14211425
go func() {
14221426
// Get all crontabs for name syncing
1423-
crontabs, err := lib.GetAllCrontabs()
1427+
crontabs, err := lib.GetAllCrontabs(parseUsers())
14241428
if err != nil {
14251429
log(fmt.Sprintf("Warning: Failed to get crontabs for name syncing: %v", err))
14261430
return
@@ -2153,7 +2157,7 @@ func handleGetCrontabs(w http.ResponseWriter, r *http.Request) {
21532157

21542158
// Read all crontabs
21552159
var crontabs []*lib.Crontab
2156-
crontabs, err := lib.GetAllCrontabs()
2160+
crontabs, err := lib.GetAllCrontabs(parseUsers())
21572161
if err != nil {
21582162
http.Error(w, err.Error(), http.StatusInternalServerError)
21592163
return

cmd/discover.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,20 @@ Example where you perform a dry-run without any crontab modifications:
188188
}
189189
}
190190
} else {
191-
// Without a supplied argument look at the user crontab, the system crontab and the system drop-in directory
191+
// Without a supplied argument look at user crontabs, the system crontab and the system drop-in directory
192192
processingMultipleCrontabs = true
193193

194-
if processCrontab(lib.CrontabFactory(username, fmt.Sprintf("user:%s", username))) {
195-
importedCrontabs++
194+
// Process crontabs for all configured users
195+
users := parseUsers()
196+
if len(users) == 0 {
197+
// Default to current user if no users configured
198+
users = []string{username}
199+
}
200+
201+
for _, user := range users {
202+
if processCrontab(lib.CrontabFactory(user, fmt.Sprintf("user:%s", user))) {
203+
importedCrontabs++
204+
}
196205
}
197206

198207
if systemCrontab := lib.CrontabFactory(username, lib.SYSTEM_CRONTAB); systemCrontab.Exists() {

cmd/list.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,17 @@ Example:
4545
crontabs = lib.ReadCrontabFromFile(username, args[0], crontabs)
4646
}
4747
} else {
48-
// Without a supplied argument look at the user crontab, system crontab, and the system drop-in directory
49-
crontabs = lib.ReadCrontabFromFile(username, fmt.Sprintf("user:%s", username), crontabs)
48+
// Without a supplied argument look at user crontabs, system crontab, and the system drop-in directory
49+
// Process crontabs for all configured users
50+
users := parseUsers()
51+
if len(users) == 0 {
52+
// Default to current user if no users configured
53+
users = []string{username}
54+
}
55+
56+
for _, user := range users {
57+
crontabs = lib.ReadCrontabFromFile(user, fmt.Sprintf("user:%s", user), crontabs)
58+
}
5059
crontabs = lib.ReadCrontabFromFile(username, lib.SYSTEM_CRONTAB, crontabs)
5160
crontabs = lib.ReadCrontabsInDirectory(username, lib.DROP_IN_DIRECTORY, crontabs)
5261
}

cmd/root.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ var hostname string
3838
var pingApiKey string
3939
var verbose bool
4040
var noStdoutPassthru bool
41+
var users string
4142

4243
// RootCmd represents the base command when called without any subcommands
4344
var RootCmd = &cobra.Command{
@@ -66,6 +67,7 @@ var varConfig = "CRONITOR_CONFIG"
6667
var varDashUsername = "CRONITOR_DASH_USER"
6768
var varDashPassword = "CRONITOR_DASH_PASS"
6869
var varAllowedIPs = "CRONITOR_ALLOWED_IPS"
70+
var varUsers = "CRONITOR_USERS"
6971

7072
func init() {
7173
userAgent = fmt.Sprintf("CronitorCLI/%s", Version)
@@ -81,6 +83,7 @@ func init() {
8183
RootCmd.PersistentFlags().StringVarP(&hostname, "hostname", "n", hostname, "A unique identifier for this host (default: system hostname)")
8284
RootCmd.PersistentFlags().StringVarP(&debugLog, "log", "l", debugLog, "Write debug logs to supplied file")
8385
RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", verbose, "Verbose output")
86+
RootCmd.PersistentFlags().StringVarP(&users, "users", "u", users, "Comma-separated list of users whose crontabs to include (default: current user only)")
8487

8588
RootCmd.PersistentFlags().BoolVar(&dev, "use-dev", dev, "Dev mode")
8689
RootCmd.PersistentFlags().MarkHidden("use-dev")
@@ -94,6 +97,7 @@ func init() {
9497
viper.BindPFlag(varConfig, RootCmd.PersistentFlags().Lookup("config"))
9598
viper.BindPFlag(varDashUsername, RootCmd.PersistentFlags().Lookup("dash-username"))
9699
viper.BindPFlag(varDashPassword, RootCmd.PersistentFlags().Lookup("dash-password"))
100+
viper.BindPFlag(varUsers, RootCmd.PersistentFlags().Lookup("users"))
97101
}
98102

99103
// initConfig reads in config file and ENV variables if set.
@@ -447,3 +451,23 @@ func configFilePath() string {
447451
// Fall back to default location if no custom path specified
448452
return fmt.Sprintf("%s/cronitor.json", defaultConfigFileDirectory())
449453
}
454+
455+
// parseUsers parses the CRONITOR_USERS config value into a slice of usernames
456+
func parseUsers() []string {
457+
usersConfig := viper.GetString(varUsers)
458+
if usersConfig == "" {
459+
return []string{} // Return empty slice for default behavior
460+
}
461+
462+
// Split by comma and clean up each username
463+
users := strings.Split(usersConfig, ",")
464+
var cleanUsers []string
465+
for _, user := range users {
466+
user = strings.TrimSpace(user)
467+
if user != "" {
468+
cleanUsers = append(cleanUsers, user)
469+
}
470+
}
471+
472+
return cleanUsers
473+
}

lib/crontab.go

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ func (c Crontab) Write() string {
271271

272272
func (c Crontab) Save(crontabLines string) error {
273273
if c.IsUserCrontab {
274-
cmd := exec.Command("crontab", "-")
274+
cmd := c.buildCrontabCommand("-")
275275

276276
// crontab will use whatever $EDITOR is set. Temporarily just cat it out.
277277
cmd.Env = []string{"EDITOR=/bin/cat"}
@@ -335,7 +335,7 @@ func (c Crontab) IsRoot() bool {
335335
func (c Crontab) Exists() bool {
336336

337337
if c.IsUserCrontab {
338-
cmd := exec.Command("crontab", "-l")
338+
cmd := c.buildCrontabCommand("-l")
339339
if _, err := cmd.CombinedOutput(); err != nil {
340340
return false
341341
}
@@ -357,7 +357,7 @@ func (c Crontab) load() ([]string, int, error) {
357357
return nil, 126, errors.New("on Windows, a crontab path argument is required")
358358
}
359359

360-
cmd := exec.Command("crontab", "-l")
360+
cmd := c.buildCrontabCommand("-l")
361361
if b, err := cmd.CombinedOutput(); err == nil {
362362
crontabBytes = b
363363
} else {
@@ -856,25 +856,33 @@ func ReadCrontabFromFile(username, filename string, crontabs []*Crontab) []*Cron
856856
}
857857

858858
// GetAllCrontabs returns a slice of all available Crontab objects.
859-
func GetAllCrontabs() ([]*Crontab, error) {
859+
func GetAllCrontabs(users []string) ([]*Crontab, error) {
860860
var crontabs []*Crontab
861-
var username string
862861

863-
// Get current username for user crontabs
862+
// Get current user for system crontab operations
863+
var currentUser string
864864
if u, err := user.Current(); err == nil {
865-
username = u.Username
865+
currentUser = u.Username
866866
}
867867

868-
// Read user crontab
869-
crontabs = ReadCrontabFromFile(username, CurrentUserCrontab(), crontabs)
868+
// If no users specified, default to current user
869+
if len(users) == 0 {
870+
users = []string{currentUser}
871+
}
872+
873+
// Read user crontabs for all specified users
874+
for _, username := range users {
875+
userCrontabPath := fmt.Sprintf("user:%s", username)
876+
crontabs = ReadCrontabFromFile(username, userCrontabPath, crontabs)
877+
}
870878

871-
// Read system crontab if it exists
872-
if systemCrontab := CrontabFactory(username, SYSTEM_CRONTAB); systemCrontab.Exists() {
873-
crontabs = ReadCrontabFromFile(username, SYSTEM_CRONTAB, crontabs)
879+
// Read system crontab if it exists (always use current user)
880+
if systemCrontab := CrontabFactory(currentUser, SYSTEM_CRONTAB); systemCrontab.Exists() {
881+
crontabs = ReadCrontabFromFile(currentUser, SYSTEM_CRONTAB, crontabs)
874882
}
875883

876-
// Read crontabs from drop-in directory
877-
crontabs = ReadCrontabsInDirectory(username, DROP_IN_DIRECTORY, crontabs)
884+
// Read crontabs from drop-in directory (always use current user)
885+
crontabs = ReadCrontabsInDirectory(currentUser, DROP_IN_DIRECTORY, crontabs)
878886

879887
return crontabs, nil
880888
}
@@ -910,3 +918,18 @@ func (c *Crontab) lightweightCopy() Crontab {
910918
UsesSixFieldExpressions: c.UsesSixFieldExpressions,
911919
}
912920
}
921+
922+
// buildCrontabCommand builds a crontab command with appropriate user flag if needed
923+
func (c Crontab) buildCrontabCommand(args ...string) *exec.Cmd {
924+
if c.User != "" {
925+
// Check if we need to add -u flag (when accessing another user's crontab)
926+
if currentUser, err := user.Current(); err == nil && currentUser.Username != c.User {
927+
// Insert -u flag and username before other arguments
928+
cmdArgs := []string{"-u", c.User}
929+
cmdArgs = append(cmdArgs, args...)
930+
return exec.Command("crontab", cmdArgs...)
931+
}
932+
}
933+
// Default case: run crontab command without -u flag (for current user)
934+
return exec.Command("crontab", args...)
935+
}

web/src/components/Jobs.js

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import useSWR from 'swr';
33
import { JobCard } from './jobs/JobCard';
44
import { Toast } from './Toast';
55
import { NewCrontabOverlay } from './jobs/NewCrontabOverlay';
6-
import { useSearchParams, useLocation } from 'react-router-dom';
6+
import { useSearchParams, useLocation, Link } from 'react-router-dom';
77
import { FilterBar, FILTER_OPTIONS } from './jobs/FilterBar';
88
import { csrfFetcher, csrfFetch } from '../utils/api';
99

@@ -226,7 +226,7 @@ export default function Jobs() {
226226

227227
// Merge monitor data with jobs
228228
const jobsWithMonitors = React.useMemo(() => {
229-
if (!jobs || !monitors) return jobs;
229+
if (!jobs || !monitors) return jobs || [];
230230

231231
return jobs.map(job => {
232232
// If job has a code (should be monitored) but no monitor found, treat as unmonitored
@@ -262,7 +262,7 @@ export default function Jobs() {
262262

263263
// Filter jobs based on active filters and search term
264264
const filteredJobs = React.useMemo(() => {
265-
if (!jobsWithMonitors) return [];
265+
if (!jobsWithMonitors || jobsWithMonitors.length === 0) return [];
266266

267267
return jobsWithMonitors.filter(job => {
268268
// Exclude meta cron jobs (system plumbing jobs like run-parts) from Jobs view
@@ -348,7 +348,7 @@ export default function Jobs() {
348348
</div>
349349
</div>
350350
);
351-
if (!jobsWithMonitors) return <div className="text-gray-600 dark:text-gray-300">Loading...</div>;
351+
if (jobs === undefined) return <div className="text-gray-600 dark:text-gray-300">Loading...</div>;
352352

353353
const handleSaveNewJob = async () => {
354354
try {
@@ -480,17 +480,49 @@ export default function Jobs() {
480480
/>
481481
))
482482
) : (
483-
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 text-center">
484-
<p className="text-gray-600 dark:text-gray-300">
485-
{jobsWithMonitors.length > 0
486-
? 'No jobs match your current filters'
487-
: 'No jobs found. Click "Add Job" to create one.'}
488-
</p>
483+
<div>
484+
{jobsWithMonitors.length > 0 ? (
485+
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 text-center">
486+
<p className="text-gray-600 dark:text-gray-300">
487+
No jobs match your current filters
488+
</p>
489+
</div>
490+
) : (
491+
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
492+
<div className="flex">
493+
<div className="flex-shrink-0">
494+
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
495+
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
496+
</svg>
497+
</div>
498+
<div className="ml-3 flex-1">
499+
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">No cron jobs found</h3>
500+
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
501+
<p>This could mean:</p>
502+
<ul className="list-disc list-inside mt-1 space-y-1">
503+
<li>You don't have any cron jobs</li>
504+
<li>You are not scanning the cron users on this host</li>
505+
<li>This dashboard does not have permissions to manage crontabs for the cron users on this host</li>
506+
</ul>
507+
<p className="mt-3">
508+
You can adjust which user crontabs are scanned from the{' '}
509+
<Link
510+
to="/settings"
511+
className="font-medium text-yellow-800 dark:text-yellow-200 hover:text-yellow-900 dark:hover:text-yellow-100 underline"
512+
>
513+
Settings page
514+
</Link>
515+
</p>
516+
</div>
517+
</div>
518+
</div>
519+
</div>
520+
)}
489521
</div>
490522
)}
491523
{(() => {
492524
// Only count non-meta jobs as potentially visible
493-
const nonMetaJobs = jobsWithMonitors.filter(job => !job.is_meta_cron_job);
525+
const nonMetaJobs = (jobsWithMonitors || []).filter(job => !job.is_meta_cron_job);
494526

495527
// Apply only user filters to non-meta jobs (not the automatic meta filter)
496528
const nonMetaJobsAfterUserFilters = nonMetaJobs.filter(job => {

0 commit comments

Comments
 (0)