this repo has no description

fix handling of cron jobs

Changed files
+105 -86
cmd
example
schemas
+38 -30
cmd/crons.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "io" 8 - "log" 7 + "log/slog" 9 8 "os" 10 9 "path/filepath" 11 10 "strings" ··· 29 28 func NewCmdCrons() *cobra.Command { 30 29 var flags struct { 31 30 json bool 31 + app string 32 32 all bool 33 33 } 34 34 35 35 cmd := &cobra.Command{ 36 - Use: "crons [app]", 37 - Aliases: []string{"cron"}, 38 - Args: cobra.MaximumNArgs(1), 39 - ValidArgsFunction: completeApp, 40 - PreRunE: func(cmd *cobra.Command, args []string) error { 41 - if len(args) > 0 && flags.all { 42 - return fmt.Errorf("cannot set both --all and specify an app") 43 - } 44 - 45 - return nil 46 - }, 47 - Short: "List cron jobs", 36 + Use: "crons", 37 + Aliases: []string{"cron"}, 38 + Args: cobra.NoArgs, 39 + Short: "List cron jobs", 48 40 RunE: func(cmd *cobra.Command, args []string) error { 49 41 var crons []CronItem 50 42 apps, err := app.ListApps(k.String("dir")) ··· 142 134 143 135 cmd.Flags().BoolVar(&flags.json, "json", false, "output as json") 144 136 cmd.Flags().BoolVar(&flags.all, "all", false, "show all cron jobs") 137 + cmd.Flags().StringVarP(&flags.app, "app", "a", "", "the app to show cron jobs for") 138 + cmd.RegisterFlagCompletionFunc("app", completeApp) 139 + cmd.MarkFlagsMutuallyExclusive("app", "all") 145 140 146 141 return cmd 147 142 } 148 143 149 - func CronRunner(stdout, stderr io.Writer) *cron.Cron { 144 + func CronRunner(logger *slog.Logger) *cron.Cron { 150 145 parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) 151 146 c := cron.New(cron.WithParser(parser)) 152 147 _, _ = c.AddFunc("* * * * *", func() { 153 - rounded := time.Now().Truncate(time.Minute) 154 - for _, appname := range k.MapKeys("apps") { 155 - for _, job := range k.Slices(fmt.Sprintf("apps.%s.crons", appname)) { 156 - sched, err := parser.Parse(job.String("schedule")) 148 + apps, err := app.ListApps(k.String("dir")) 149 + if err != nil { 150 + logger.Error("failed to list apps", "error", err) 151 + return 152 + } 153 + 154 + for _, appname := range apps { 155 + a, err := app.LoadApp(appname, k.String("dir"), k.String("domain")) 156 + if err != nil { 157 + logger.Error("failed to load app", "app", appname, "error", err) 158 + continue 159 + } 160 + 161 + current := time.Now().Truncate(time.Minute) 162 + for _, job := range a.Config.Crons { 163 + sched, err := parser.Parse(job.Schedule) 157 164 if err != nil { 158 - fmt.Println(err) 165 + logger.Error("failed to parse cron schedule", "app", appname, "schedule", job.Schedule, "error", err) 159 166 continue 160 167 } 161 168 162 - if sched.Next(rounded.Add(-1*time.Second)) != rounded { 169 + if sched.Next(current.Add(-1*time.Second)) != current { 163 170 continue 164 171 } 165 172 166 - a, err := app.LoadApp(appname, k.String("rootDir"), k.String("domain")) 173 + a, err := app.LoadApp(appname, k.String("dir"), k.String("domain")) 167 174 if err != nil { 168 - fmt.Println(err) 175 + logger.Error("failed to load app", "app", appname, "error", err) 169 176 continue 170 177 } 171 178 wk := worker.NewWorker(a, k.Bool(fmt.Sprintf("apps.%s.admin", a.Name)), nil) 172 179 173 - command, err := wk.Command(context.Background(), job.Strings("args"), nil) 180 + command, err := wk.Command(context.Background(), job.Args, nil) 174 181 if err != nil { 175 - fmt.Println(err) 182 + logger.Error("failed to create command", "app", appname, "args", job.Args, "error", err) 176 183 continue 177 184 } 178 - command.Stdout = stdout 179 - command.Stderr = stderr 180 185 181 - if err := command.Run(); err != nil { 182 - log.Printf("failed to run command: %v", err) 183 - } 186 + logger.Info("running cron job", "app", appname, "args", job.Args, "schedule", job.Schedule) 187 + go func() { 188 + if err := command.Run(); err != nil { 189 + logger.Error("failed to run command", "app", appname, "args", job.Args, "error", err) 190 + } 191 + }() 184 192 } 185 193 } 186 194 })
+12 -6
cmd/open.go
··· 12 12 ) 13 13 14 14 func NewCmdOpen() *cobra.Command { 15 + var flags struct { 16 + app string 17 + } 18 + 15 19 cmd := &cobra.Command{ 16 - Use: "open [app]", 17 - Short: "Open an app in the browser", 18 - Args: cobra.MaximumNArgs(1), 19 - ValidArgsFunction: completeApp, 20 + Use: "open [app]", 21 + Short: "Open an app in the browser", 22 + Args: cobra.NoArgs, 20 23 RunE: func(cmd *cobra.Command, args []string) error { 21 - if len(args) == 0 { 24 + if flags.app == "" { 22 25 cwd, err := os.Getwd() 23 26 if err != nil { 24 27 return fmt.Errorf("failed to get current directory: %w", err) ··· 46 49 return nil 47 50 } 48 51 49 - a, err := app.LoadApp(args[0], k.String("dir"), k.String("domain")) 52 + a, err := app.LoadApp(flags.app, k.String("dir"), k.String("domain")) 50 53 if err != nil { 51 54 return fmt.Errorf("failed to load app: %w", err) 52 55 } ··· 58 61 return nil 59 62 }, 60 63 } 64 + 65 + cmd.Flags().StringVarP(&flags.app, "app", "a", "", "The app to open") 66 + cmd.RegisterFlagCompletionFunc("app", completeApp) 61 67 62 68 return cmd 63 69 }
+25 -7
cmd/secrets.go
··· 6 6 "fmt" 7 7 "os" 8 8 "path/filepath" 9 + "strings" 9 10 10 11 "github.com/cli/go-gh/v2/pkg/tableprinter" 11 12 "github.com/getsops/sops/v3/decrypt" ··· 23 24 func NewCmdSecrets() *cobra.Command { 24 25 var flags struct { 25 26 json bool 27 + app string 26 28 dotenv bool 27 29 } 28 30 29 31 cmd := &cobra.Command{ 30 - Use: "secrets [app]", 31 - Short: "Print app secrets", 32 - ValidArgsFunction: completeApp, 33 - Args: cobra.MaximumNArgs(1), 32 + Use: "secrets [app]", 33 + Short: "Print app secrets", 34 + Args: cobra.NoArgs, 34 35 RunE: func(cmd *cobra.Command, args []string) error { 35 - if len(args) == 0 { 36 - return fmt.Errorf("app name is required") 36 + appName := flags.app 37 + if appName == "" { 38 + cwd, err := os.Getwd() 39 + if err != nil { 40 + return fmt.Errorf("failed to get current directory: %w", err) 41 + } 42 + 43 + if !strings.HasPrefix(cwd, k.String("dir")) { 44 + return fmt.Errorf("not in an app directory") 45 + } 46 + 47 + appDir := cwd 48 + for filepath.Dir(appDir) != k.String("dir") { 49 + appDir = filepath.Dir(appDir) 50 + } 51 + 52 + appName = filepath.Base(appDir) 37 53 } 38 54 39 - secretPath := filepath.Join(k.String("dir"), args[0], "secrets.enc.env") 55 + secretPath := filepath.Join(k.String("dir"), appName, "secrets.enc.env") 40 56 41 57 rawBytes, err := os.ReadFile(secretPath) 42 58 if err != nil { ··· 107 123 108 124 cmd.Flags().BoolVar(&flags.json, "json", false, "Output as JSON") 109 125 cmd.Flags().BoolVar(&flags.dotenv, "dotenv", false, "Output as dotenv") 126 + cmd.Flags().StringVar(&flags.app, "app", "", "App name") 127 + cmd.RegisterFlagCompletionFunc("app", completeApp) 110 128 111 129 return cmd 112 130
+17 -33
cmd/up.go
··· 49 49 "github.com/spf13/cobra" 50 50 ) 51 51 52 - var ErrSilent = errors.New("exit error") 53 - 54 52 func NewCmdUp() *cobra.Command { 55 53 var flags struct { 56 54 enableCrons bool ··· 66 64 } 67 65 68 66 cmd := &cobra.Command{ 69 - Use: "up", 70 - Short: "Start the smallweb evaluation server", 71 - Aliases: []string{"serve"}, 72 - Args: cobra.NoArgs, 73 - SilenceErrors: true, 67 + Use: "up", 68 + Short: "Start the smallweb evaluation server", 69 + Aliases: []string{"serve"}, 70 + Args: cobra.NoArgs, 74 71 PreRunE: func(cmd *cobra.Command, args []string) error { 75 72 if _, err := checkDenoVersion(); err != nil { 76 73 return err ··· 95 92 96 93 if k.String("dir") == "" { 97 94 logger.Error("dir cannot be empty") 98 - return ErrSilent 95 + return fmt.Errorf("dir cannot be empty") 99 96 } 100 97 101 98 if k.String("domain") == "" { 102 - logger.Error("domain cannot be empty") 103 - return ErrSilent 99 + return fmt.Errorf("domain cannot be empty") 104 100 } 105 101 106 102 handler := &Handler{ ··· 138 134 } 139 135 }) 140 136 if err != nil { 141 - logger.Error("failed to create watcher") 142 - return ErrSilent 137 + return fmt.Errorf("failed to create watcher: %w", err) 143 138 } 144 139 145 140 handler.watcher = watcher ··· 153 148 if flags.tlsCert != "" && flags.tlsKey != "" { 154 149 cert, err := tls.LoadX509KeyPair(flags.tlsCert, flags.tlsKey) 155 150 if err != nil { 156 - logger.Error("failed to load tls certificate", "error", err) 157 - return ErrSilent 151 + return fmt.Errorf("failed to load tls certificate: %w", err) 158 152 } 159 153 160 154 tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} ··· 167 161 168 162 ln, err := getListener(addr, tlsConfig) 169 163 if err != nil { 170 - logger.Error("failed to get listener", "error", err) 171 - return ErrSilent 164 + return fmt.Errorf("failed to get listener: %w", err) 172 165 } 173 166 174 167 logger.Info("serving https", "domain", k.String("domain"), "dir", k.String("dir"), "addr", addr) ··· 198 191 199 192 ln, err := getListener(addr, nil) 200 193 if err != nil { 201 - logger.Error("failed to get listener", "error", err) 202 - return ErrSilent 194 + return fmt.Errorf("failed to get listener: %w", err) 203 195 } 204 196 205 197 logger.Info("serving http", "domain", k.String("domain"), "dir", k.String("dir"), "addr", addr) ··· 208 200 209 201 if flags.enableCrons { 210 202 logger.Info("starting cron jobs") 211 - crons := CronRunner(cmd.OutOrStdout(), cmd.ErrOrStderr()) 203 + crons := CronRunner(logger.With("logger", "cron")) 212 204 crons.Start() 213 205 defer crons.Stop() 214 206 } ··· 257 249 if flags.sshPrivateKey == "" { 258 250 homeDir, err := os.UserHomeDir() 259 251 if err != nil { 260 - logger.Error("failed to get home directory", "error", err) 261 - return ErrSilent 252 + return fmt.Errorf("failed to get home directory: %w", err) 262 253 } 263 254 264 255 for _, keyType := range []string{"id_rsa", "id_ed25519"} { ··· 270 261 } 271 262 272 263 if sshPrivateKeyPath == "" { 273 - logger.Error("ssh private key not found") 274 - return ErrSilent 264 + return fmt.Errorf("ssh private key not found") 275 265 } 276 266 277 267 privateKeyBytes, err := os.ReadFile(sshPrivateKeyPath) 278 268 if err != nil { 279 - logger.Error("failed to read private key", "error", err) 280 - return ErrSilent 269 + return fmt.Errorf("failed to read private key: %w", err) 281 270 } 282 271 283 272 privateKey, err := gossh.ParseRawPrivateKey(privateKeyBytes) 284 273 if err != nil { 285 - logger.Error("failed to parse private key", "error", err) 286 - return ErrSilent 274 + return fmt.Errorf("failed to parse private key: %w", err) 287 275 } 288 276 289 277 signer, err := gossh.NewSignerFromKey(privateKey) 290 278 if err != nil { 291 - logger.Error("failed to create signer", "error", err) 292 - return ErrSilent 279 + return fmt.Errorf("failed to create signer: %w", err) 293 280 } 294 281 295 282 authorizedKey := string(gossh.MarshalAuthorizedKey(signer.PublicKey())) ··· 406 393 407 394 if err != nil { 408 395 logger.Error("failed to create ssh server", "error", err) 409 - return ErrSilent 396 + return fmt.Errorf("failed to create ssh server: %w", err) 410 397 } 411 398 412 399 logger.Info("serving ssh", "addr", flags.sshAddr) ··· 432 419 cmd.Flags().BoolVar(&flags.onDemandTLS, "on-demand-tls", false, "enable on-demand tls") 433 420 cmd.Flags().StringVar(&flags.logFormat, "log-format", "pretty", "log format (json or text)") 434 421 cmd.Flags().BoolVar(&flags.enableCrons, "enable-crons", false, "enable cron jobs") 435 - cmd.Flags().Bool("cron", false, "enable cron jobs") 436 422 437 - cmd.Flags().MarkDeprecated("cron", "use --enable-crons instead") 438 - cmd.Flags().MarkDeprecated("ssh-host-key", "use --ssh-private-key instead") 439 423 cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "tls-cert") 440 424 cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "tls-key") 441 425 cmd.MarkFlagsMutuallyExclusive("on-demand-tls", "addr")
+5
example/cron/main.ts
··· 1 + export default { 2 + run() { 3 + console.log('Cron job running...'); 4 + } 5 + }
+8
example/cron/smallweb.json
··· 1 + { 2 + "crons": [ 3 + { 4 + "schedule": "* * * * *", 5 + "args": [] 6 + } 7 + ] 8 + }
-6
main.go
··· 2 2 3 3 import ( 4 4 _ "embed" 5 - "errors" 6 - "fmt" 7 5 "os" 8 6 9 7 "github.com/pomdtr/smallweb/cmd" ··· 17 15 root.SetErr(os.Stderr) 18 16 19 17 if err := root.Execute(); err != nil { 20 - if !errors.Is(err, cmd.ErrSilent) { 21 - fmt.Fprintln(os.Stderr, err) 22 - } 23 - 24 18 os.Exit(1) 25 19 } 26 20 }
-4
schemas/manifest.schema.json
··· 42 42 "description": "Cron schedule", 43 43 "type": "string" 44 44 }, 45 - "description": { 46 - "type": "string", 47 - "description": "An optional description for the task" 48 - }, 49 45 "args": { 50 46 "description": "Cron arguments", 51 47 "type": "array",