this repo has no description

fix handling of cron jobs

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