├── .dockerignore ├── .env ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── check.go ├── generate_crontab.go ├── list.go ├── pg_reload.go ├── restore.go ├── run.go └── start.go ├── config ├── pg_reloaded.service └── supervisor.conf ├── cron ├── LICENSE ├── constantdelay.go ├── constantdelay_test.go ├── cron.go ├── cron_test.go ├── doc.go ├── parser.go ├── parser_test.go ├── simple.go ├── simple_test.go ├── spec.go ├── spec_test.go ├── tz.go └── tz_test.go ├── go.mod ├── go.sum ├── logo.svg ├── main.go ├── openapi.yaml ├── pg_reloaded-sample.yml └── pg_reloaded ├── commands.go ├── commands_test.go ├── config.go └── config_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | dist/ 4 | vendor/ 5 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | GO111MODULE=on 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode/ 2 | /build/ 3 | /dist/ 4 | /vendor/ 5 | development.yml 6 | *.exe 7 | test-db -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Goreleaser configuration 2 | before: 3 | hooks: 4 | # you may remove this if you don't use vgo 5 | - go mod download 6 | # you may remove this if you don't need go generate 7 | - go generate ./... 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - freebsd 13 | - windows 14 | - darwin 15 | - linux 16 | archives: 17 | - replacements: 18 | darwin: Darwin 19 | linux: Linux 20 | windows: Windows 21 | 386: i386 22 | amd64: x86_64 23 | checksum: 24 | name_template: 'checksums.txt' 25 | snapshot: 26 | name_template: "{{ .Tag }}-next" 27 | changelog: 28 | sort: asc 29 | filters: 30 | exclude: 31 | - '^docs:' 32 | - '^test:' 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG IMAGE=postgres:11.2-alpine 2 | 3 | FROM golang:1.14-alpine AS builder 4 | RUN apk add --no-cache git make 5 | ENV GO111MODULE on 6 | COPY . /go/src/github.com/nndi-oss/pg_reloaded/ 7 | RUN cd /go/src/github.com/nndi-oss/pg_reloaded/ && make build BINARY=/dist/pg_reloaded 8 | 9 | FROM $IMAGE 10 | COPY --from=builder /dist/pg_reloaded /usr/bin/pg_reloaded 11 | CMD ["/usr/bin/pg_reloaded", "start", "--config", "/etc/pg_reloaded/pg_reloaded.yml"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Zikani Nyirenda Mwase 4 | Copyright (c) 2020 NNDI 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | -include .env 2 | MODIFIER=-master 3 | DOCKERCMD=docker 4 | DOCKERBUILD=$(DOCKERCMD) build . 5 | DOCKERPUSH=$(DOCKERCMD) push 6 | DOCKERTAG=$(DOCKERCMD) tag 7 | 8 | GOCMD=go 9 | BINARY=./dist/pg_reloaded 10 | 11 | all: build 12 | build: deps 13 | $(GOCMD) build -o $(BINARY) 14 | deps: 15 | $(GOCMD) list -m all 16 | 17 | docker: 18 | COMMIT=$$(git rev-parse --short HEAD); \ 19 | for i in 9.2 9.3 9.4 9.5 9.6 10 11 12 13 14; \ 20 | do \ 21 | $(DOCKERBUILD) --build-arg IMAGE=postgres:$$i-alpine -t gkawamoto/pg_reloaded:$$i$(MODIFIER)-alpine; \ 22 | $(DOCKERTAG) gkawamoto/pg_reloaded:$$i$(MODIFIER)-alpine gkawamoto/pg_reloaded:$$COMMIT-$$i$(MODIFIER)-alpine; \ 23 | done 24 | docker-publish: docker 25 | COMMIT=$$(git rev-parse --short HEAD); \ 26 | for i in 9.2 9.3 9.4 9.5 9.6 10 11 12 13 14; \ 27 | do \ 28 | $(DOCKERPUSH) gkawamoto/pg_reloaded:$$i$(MODIFIER)-alpine; \ 29 | $(DOCKERPUSH) gkawamoto/pg_reloaded:$$COMMIT-$$i$(MODIFIER)-alpine; \ 30 | done 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | PG Reloaded logo 3 |

4 | 5 | PG Reloaded 6 | === 7 | 8 | `pg_reloaded` is a program that's useful for restoring databases. You can use it to refresh databases for online demos, development databases and anywhere where you may want to reset the data after use. You schedule your databases to be restored from a backup. 9 | 10 | ## Installation 11 | 12 | Currently, you will have to build it from source, binary Releases will be made available soon. 13 | 14 | ## Usage 15 | 16 | Get the usage information by running `pg_reloaded --help` 17 | 18 | ```sh 19 | $ pg_reloaded check --config="pg_reloaded.yml" 20 | 21 | # Run with the default configuration 22 | $ pg_reloaded start 23 | 24 | # Or with a path to a configuration file 25 | $ pg_reloaded start --config="development.yml" --log-file="./path/to/log" 26 | ``` 27 | 28 | If you would like to restore a database immediately, run the following: 29 | 30 | ```sh 31 | $ pg_reloaded run "database" 32 | ``` 33 | 34 | In order to be effective, `pg_reloaded` needs to run in the background as a daemon. 35 | There's a sample [Systemd Unit File here](./config/pg_reloaded.service). 36 | 37 | You can also use supervisor to run the `pg_reloaded` daemon, below is an example 38 | configuration for [Supervisor](https://github.com/supervisor/supervisor) 39 | 40 | ```ini 41 | [program:pg_reloaded] 42 | command=/usr/bin/pg_reloaded start --config=/etc/pg_reloaded/pg_reloaded.yml 43 | ``` 44 | 45 | Please note that these process management systems must be configured to 46 | start pg_reloaded on boot for best effective use of the scheduling capabilities. 47 | 48 | ## Example configuration 49 | 50 | PG Reloaded is configured via YAML configuration file which records the details 51 | about how and when to restore your databases. 52 | 53 | By default `pg_reloaded` reads configuration from a file named `pg_reloaded.yml` 54 | in the home directory if present i.e. `$HOME/pg_reloaded.yml` 55 | (on Windows in `%UserProfile%\pg_reloaded.yml`) 56 | 57 | You can specify a path for the configuration file via the `--config` option 58 | on the command-line. 59 | 60 | The configuration basically looks like the following: 61 | 62 | ```yaml 63 | # Absolute path to the directory containing postgresql client programs 64 | # The following client programs are searched for specifically: 65 | # psql, pg_restore, pg_dump 66 | psql_path: "/path/to/psql-dir" 67 | # Absolute path to the logfile, will be created if it does not exist 68 | log_file: "/path/to/logfile" 69 | servers: 70 | # name - A name to identify the server in the "databases" section of the configuration 71 | - name: "my-development-server" 72 | # host - The host for the database 73 | host: "localhost" 74 | # port - The port for the database 75 | port: 5432 76 | # Username for the user must have CREATE DATABASE & DROP DATABASE privileges 77 | username: "appuser" 78 | # Password for the user role on the database 79 | password: "password" 80 | databases: 81 | # name - The database name 82 | - name: "my_database_name" 83 | # server - The name of a server in the "servers" list 84 | server: "my-development-server" 85 | # schedule - Specifies the schedule for running the database restores in 86 | # daemon mode. Supports simple interval notation and CRON expressions 87 | schedule: "@every 24h" 88 | # Source specifies where to get the schema and data to restore 89 | source: 90 | # The type of file(s) to restore the database from. 91 | # The following types are (will be) supported: 92 | # 93 | # * sql - load schema & data from SQL files using psql 94 | # * tar - load schema & data from SQL files using pg_restore 95 | # * csv - load data from CSV files using pgfutter 96 | # * json - load data from JSON files using pgfutter 97 | type: "sql" 98 | 99 | # The absolute path to the file to restore the database from 100 | file: "/path/to/file" 101 | 102 | # The absolute path to the schema file to be used to create tables, functions etc.. 103 | # Schema MUST be specified if source type is one of: csv, json 104 | # or if the SQL file only contains data 105 | schema: "/path/to/schema/file.sql" 106 | ``` 107 | 108 | ### Supported notation for Scheduling 109 | 110 | The real value of pg_reloaded is in its ability to restore databases according 111 | to a schedule. 112 | 113 | The following syntax is supported for scheduling 114 | 115 | * Intervals: Simple interval notation is supported. Only for seconds(`s`), 116 | minutes (`m`) and hours (`h`). 117 | 118 | e.g. `@every 10m`, `@every 2h`, `@weekly`, `@monthly` 119 | 120 | * CRON Expression: Most CRON expressions valid [here](http://crontab.guru) should be valid 121 | 122 | 123 | ## Prerequisites 124 | 125 | * The postgresql client programs must be present on your path or configured in 126 | the config file or command-line for `pg_reloaded` to work. In particular the 127 | program may need to execute `psql`, `pg_restore` or `pg_dump` during it's operation. 128 | 129 | In the YAML file: 130 | 131 | ```yaml 132 | psql_path: "/path/to/psql-dir/" 133 | ``` 134 | 135 | On the command-line 136 | 137 | ```sh 138 | $ pg_reloaded --psql-path="/path/to/psql-dir" 139 | ``` 140 | 141 | ### Supported Sources 142 | 143 | Currently, the only supported sources are: 144 | 145 | * *SQL* via dumped *SQL file* - default source, load dumps/data files from the filesystem 146 | 147 | ## Docker 148 | 149 | You can build Docker images/containers using the [Dockerfile](./Dockerfile). 150 | At this time, you can pull images from [@gkawamoto's](https://github.com/gkawamoto) Dockerhub: 151 | 152 | ``` 153 | $ docker pull gkawamoto/pg_reloaded 154 | ``` 155 | 156 | ## Building from Source 157 | 158 | I encourage you to build pg_reloaded from source, if only to get you to try out 159 | Go ;). So the first step is to Install Go. 160 | 161 | The next step is to clone the repo and then after that, building the binary _should_ 162 | be as simple as running `go build` 163 | 164 | ```sh 165 | $ git clone https://github.com/nndi-oss/pg_reloaded.git 166 | $ cd pg_reloaded 167 | $ go build -o dist/pg_reloaded 168 | ``` 169 | 170 | ### Dependencies 171 | 172 | This project could not be made possible without these great Open-Source 173 | tools and their authors/contributors whose shoulders are steady enough to stand on: 174 | 175 | * [Go 1.12.x](https://golang.org) 176 | * [Postgresql (Ofcourse!)](https://postgresql.org) 177 | * [cobra](https://github.com/spf13/cobra) 178 | * [viper](https://github.com/spf13/viper) 179 | * The [cron](./cron) package is copied from [dkron](https://github.com/victorcoder/dkron/tree/master/cron) 180 | 181 | ## CHENJEZO ( *Notice* ) 182 | 183 | - **Running as a Service on Windows** 184 | 185 | Since you have to run it in the background for the scheduling functionality to be 186 | of any value a Service wrapper would be ideal on Windows - but until then you will have 187 | to run it in the foreground. 188 | 189 | - **BE CAREFULL, IT MAY EAT YOUR LUNCH!** 190 | 191 | This is not meant to be run on *production* databases which house critical data 192 | that you can't afford to lose. It's meant for demo and development databases that 193 | can be dropped and restored without losing a dime. Use good judgment. 194 | 195 | 196 | ## ROADMAP 197 | 198 | Some rough ideas on how to take this thing further: 199 | 200 | * Add preload and post-load conditions for databases 201 | 202 | ```yaml 203 | preload: 204 | dump: true 205 | checkActivity: true 206 | postload: 207 | notify: true 208 | ``` 209 | 210 | * Backup the current database before restoring using pg_dump or [pgclimb](https://github.com/lukasmartinelli/pgclimb) 211 | * A Windows Service wrapper 212 | * Add support for the following sources: 213 | 214 | * *csv* : loads data from CSV files, just like you would with [pgfutter](https://github.com/lukasmartinelli/pgfutter) 215 | * *json*: loads data from JSON files, just like you would with pgfutter 216 | * *postgres* - load database from a remote postgresql database 217 | * *http* - load a database dump from an HTTP server 218 | * *s3* - load database dump from AWS S3 219 | 220 | ## CONTRIBUTING 221 | 222 | Issues and Pull Requests welcome. 223 | 224 | ## License 225 | 226 | MIT License 227 | 228 | --- 229 | 230 | Copyright (c) 2019 - 2020, NNDI 231 | -------------------------------------------------------------------------------- /cmd/check.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/nndi-oss/pg_reloaded/pg_reloaded" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func init() { 12 | rootCmd.AddCommand(checkCmd) 13 | } 14 | 15 | var checkCmd = &cobra.Command{ 16 | Use: "check", 17 | Short: "Checks and validates the configuration file", 18 | Long: `Checks and validates the configuration file`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | if err := pg_reloaded.Validate(*config); err != nil { 21 | fmt.Println(err) 22 | os.Exit(1) 23 | } 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /cmd/generate_crontab.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | // Generate CRON Tab from the configuration 4 | import ( 5 | "fmt" 6 | "github.com/nndi-oss/pg_reloaded/cron" 7 | "github.com/nndi-oss/pg_reloaded/pg_reloaded" 8 | "github.com/spf13/cobra" 9 | "os" 10 | ) 11 | 12 | func init() { 13 | // TODO: rootCmd.AddCommand(generateCrontabCmd) 14 | } 15 | 16 | var cronTemplate = "%s \t %s" 17 | var generateCrontabCmd = &cobra.Command{ 18 | Use: "generate-crontab", 19 | Short: "Generates a CRON Tab from PG Reloadeds configuration", 20 | Long: `Generates a CRON Tab from PG Reloadeds configuration`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | generateCrontab(args...) 23 | }, 24 | } 25 | 26 | // ****** /bin/psql -U %s %s 27 | func generateCrontab(args ...string) { 28 | for _, d := range config.Databases { 29 | v, err := cron.Parse(d.Schedule) 30 | if err != nil { 31 | fmt.Printf("Failed to parse schedule for '%s' Error %v \n", d.Name, err) 32 | os.Exit(1) 33 | break 34 | } 35 | cronSchedule := v.(*cron.SpecSchedule) 36 | cronStr := fmt.Sprintf("%d %d %d %d %d", 37 | cronSchedule.Second, 38 | cronSchedule.Minute, 39 | cronSchedule.Hour, 40 | cronSchedule.Dom, 41 | cronSchedule.Month, 42 | ) 43 | server := config.GetServerByName(d.Server) 44 | cmdStr := pg_reloaded.DropAndRestoreUsingPsql( 45 | config.PsqlDir, 46 | server.Username, 47 | d.Name, 48 | server.Host, 49 | server.Port, 50 | d.Source.File, 51 | server.Password, 52 | ) 53 | 54 | fmt.Printf("%s\t%s\n", cronStr, cmdStr) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func init() { 10 | rootCmd.AddCommand(listCmd) 11 | } 12 | 13 | var listCmd = &cobra.Command{ 14 | Use: "list", 15 | Short: "Lists the configured servers and databases", 16 | Long: `Lists the configured servers and databases`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | 19 | fmt.Println("Servers:") 20 | fmt.Println("===========================") 21 | for _, s := range config.Servers { 22 | fmt.Println("Name: ", s.Name) 23 | fmt.Println("Host: ", s.Host) 24 | fmt.Println("Port: ", s.Port) 25 | fmt.Println("Username: ", s.Username) 26 | } 27 | fmt.Println("") 28 | fmt.Println("Databases:") 29 | fmt.Println("===========================") 30 | for _, d := range config.Databases { 31 | fmt.Println("Name:", d.Name) 32 | fmt.Println("Schedule:", d.Schedule) 33 | fmt.Println("Server:", d.Server) 34 | fmt.Println("Source:", d.Source.File) 35 | } 36 | fmt.Println("\n\ndone.") 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /cmd/pg_reload.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/hashicorp/go-hclog" 6 | homedir "github.com/mitchellh/go-homedir" 7 | "github.com/nndi-oss/pg_reloaded/pg_reloaded" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | "os" 11 | "path" 12 | ) 13 | 14 | var cfgFile string 15 | var config = &pg_reloaded.Config{} 16 | var logger hclog.Logger 17 | var psqlPath string 18 | var logFile string 19 | 20 | func init() { 21 | cobra.OnInitialize(initConfig) 22 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/pg_reloaded.yml)") 23 | rootCmd.PersistentFlags().StringVarP(&psqlPath, "psql-path", "b", "", "base project directory eg. github.com/spf13/") 24 | rootCmd.PersistentFlags().StringVarP(&logFile, "log-file", "l", "", "base project directory eg. github.com/spf13/") 25 | // TODO: rootCmd.PersistentFlags().StringVarP(&workingDir, "working-dir", "w", "", "base project directory eg. github.com/spf13/") 26 | rootCmd.PersistentFlags().Bool("vvv", true, "Verbose output") 27 | viper.BindPFlag("psql_path", rootCmd.PersistentFlags().Lookup("psql-path")) 28 | viper.BindPFlag("log_file", rootCmd.PersistentFlags().Lookup("log-file")) 29 | } 30 | 31 | func initConfig() { 32 | var home string 33 | // Don't forget to read config either from cfgFile or from home directory! 34 | if cfgFile != "" { 35 | // Use config file from the flag. 36 | viper.SetConfigFile(cfgFile) 37 | } else { 38 | // Find home directory. 39 | home, err := homedir.Dir() 40 | if err != nil { 41 | fmt.Println(err) 42 | os.Exit(1) 43 | } 44 | 45 | viper.SetConfigName("pg_reloaded") 46 | // Search config in home directory with name "pg_reloaded" (without extension). 47 | viper.AddConfigPath(home) 48 | viper.AddConfigPath("/etc/pg_reloaded") 49 | } 50 | 51 | if err := viper.ReadInConfig(); err != nil { 52 | fmt.Println("Can't read config:", err) 53 | os.Exit(1) 54 | } 55 | 56 | if err := viper.Unmarshal(config); err != nil { 57 | fmt.Println("Can't read config:", err) 58 | os.Exit(1) 59 | } 60 | 61 | logpath := path.Join(home, "pg_reloaded.log") 62 | if config.LogPath != "" { 63 | logpath = path.Join(config.LogPath, "pg_reloaded.log") 64 | } 65 | logger = hclog.New(&hclog.LoggerOptions{ 66 | Name: logpath, 67 | Level: hclog.LevelFromString("DEBUG"), 68 | }) 69 | } 70 | 71 | var rootCmd = &cobra.Command{ 72 | Use: "pg_reloaded", 73 | Short: "PG Reloaded is a tool for restoring postgresql databases periodically", 74 | Long: `PG Reloaded is a tool for restoring postgresql databases periodically 75 | for use for development and demo databases. 76 | More info: https://github.com/nndi-oss/pg_reloaded`, 77 | Run: func(cmd *cobra.Command, args []string) { 78 | if err := pg_reloaded.Validate(*config); err != nil { 79 | fmt.Println(err) 80 | os.Exit(1) 81 | } 82 | }, 83 | } 84 | 85 | func Execute() error { 86 | return rootCmd.Execute() 87 | } 88 | -------------------------------------------------------------------------------- /cmd/restore.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/nndi-oss/pg_reloaded/pg_reloaded" 6 | "github.com/spf13/cobra" 7 | "os" 8 | ) 9 | 10 | func init() { 11 | // runCmd.Flags().StringP("username", "u", "postgres", "Override the postgres user (default: postgres)") 12 | // runCmd.Flags().StringP("host", "h", "localhost", "Override the server host (default: localhost)") 13 | // runCmd.Flags().StringP("port", "p", "5432", "Override the server port (default: 5432)") 14 | 15 | rootCmd.AddCommand(restoreCmd) 16 | } 17 | 18 | var restoreCmd = &cobra.Command{ 19 | Use: "restore", 20 | Short: "Restores a backup for a specific database without dropping first", 21 | Long: `Restores a backup for a specific database without dropping first`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | dbName := args[0] 24 | var database pg_reloaded.DatabaseConfig 25 | var found bool = false 26 | for _, d := range config.Databases { 27 | if dbName == d.Name { 28 | database = d 29 | found = true 30 | break 31 | } 32 | } 33 | if !found { 34 | fmt.Println("Invalid database specified. Run 'pg_reloaded list' to see configured databases") 35 | os.Exit(1) 36 | return 37 | } 38 | 39 | server := config.GetServerByName(database.Server) 40 | 41 | host := server.Host 42 | username := server.Username 43 | password := server.Password 44 | port := server.Port 45 | sourceFile := database.Source.File 46 | 47 | err := pg_reloaded.RunRestoreDatabase( 48 | config.PsqlDir, 49 | username, 50 | dbName, 51 | host, 52 | port, 53 | sourceFile, 54 | password, 55 | ) 56 | if err != nil { 57 | fmt.Printf("Failed to restore database. Got %v", err) 58 | os.Exit(1) 59 | return 60 | } 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/nndi-oss/pg_reloaded/pg_reloaded" 6 | "github.com/spf13/cobra" 7 | "os" 8 | ) 9 | 10 | func init() { 11 | // runCmd.Flags().StringP("username", "u", "postgres", "Override the postgres user (default: postgres)") 12 | // runCmd.Flags().StringP("host", "h", "localhost", "Override the server host (default: localhost)") 13 | // runCmd.Flags().StringP("port", "p", "5432", "Override the server port (default: 5432)") 14 | 15 | rootCmd.AddCommand(runCmd) 16 | } 17 | 18 | var runCmd = &cobra.Command{ 19 | Use: "run", 20 | Short: "Run an immediate restore for a specific database", 21 | Long: `Run an immediate restore for a specific database`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | dbName := args[0] 24 | var database pg_reloaded.DatabaseConfig 25 | var found bool = false 26 | for _, d := range config.Databases { 27 | if dbName == d.Name { 28 | database = d 29 | found = true 30 | break 31 | } 32 | } 33 | if !found { 34 | fmt.Println("Invalid database specified. Run 'pg_reload list' to see configured databases") 35 | os.Exit(1) 36 | return 37 | } 38 | 39 | server := config.GetServerByName(database.Server) 40 | 41 | host := server.Host 42 | username := server.Username 43 | password := server.Password 44 | port := server.Port 45 | sourceFile := database.Source.File 46 | 47 | err := pg_reloaded.RunDropDatabase( 48 | config.PsqlDir, 49 | username, 50 | dbName, 51 | host, 52 | port, 53 | password, 54 | ) 55 | if err != nil { 56 | fmt.Printf("Failed to drop database. Got %v", err) 57 | os.Exit(1) 58 | return 59 | } 60 | err = pg_reloaded.RunRestoreDatabase( 61 | config.PsqlDir, 62 | username, 63 | dbName, 64 | host, 65 | port, 66 | sourceFile, 67 | password, 68 | ) 69 | if err != nil { 70 | fmt.Printf("Failed to restore database. Got %v", err) 71 | os.Exit(1) 72 | return 73 | } 74 | }, 75 | } 76 | -------------------------------------------------------------------------------- /cmd/start.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/nndi-oss/pg_reloaded/cron" 6 | "github.com/nndi-oss/pg_reloaded/pg_reloaded" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var scheduler = cron.New() 11 | var shutdownCh chan (struct{}) 12 | 13 | func init() { 14 | rootCmd.AddCommand(startCmd) 15 | } 16 | 17 | var startCmd = &cobra.Command{ 18 | Use: "start", 19 | Short: "Starts the PG Reloaded scheduler/cron daemon", 20 | Long: `Starts the PG Reloaded scheduler/cron daemon`, 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | if err := createJobsFromConfiguration(scheduler); err != nil { 23 | return err 24 | } 25 | 26 | scheduler.Start() 27 | select { 28 | // TODO: handle signals case s := <-signalCh: 29 | case <-shutdownCh: 30 | return nil 31 | } 32 | }, 33 | } 34 | 35 | func createJobsFromConfiguration(cronScheduler *cron.Cron) error { 36 | var server pg_reloaded.ServerConfig 37 | for _, db := range config.Databases { 38 | server = *config.GetServerByName(db.Server) 39 | username := server.Username 40 | password := server.Password 41 | err := cronScheduler.AddFunc(db.Schedule, func() { 42 | if db.Source.Type == "sql" { 43 | pg_reloaded.RunDropDatabase( 44 | config.PsqlDir, 45 | username, 46 | db.Name, 47 | server.Host, 48 | server.Port, 49 | password, 50 | ) 51 | pg_reloaded.RunRestoreDatabase( 52 | config.PsqlDir, 53 | username, 54 | db.Name, 55 | server.Host, 56 | server.Port, 57 | db.Source.File, 58 | password, 59 | ) 60 | } 61 | // TODO: create schema first: RunPsql(username, db.Name, server.Host, server.Port, db.Source.File, password) 62 | // TODO: insert data: RunPsql(username, db.Name, server.Host, server.Port, db.Source.File, password) 63 | }) 64 | if err != nil { 65 | fmt.Printf("Failed to start scheduler. Got error: %v \n", err) 66 | return err 67 | } 68 | } 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /config/pg_reloaded.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=PG Reloaded 3 | Documentation=https://pg_reloaded.github.io 4 | After=network.target 5 | 6 | [Service] 7 | User=root 8 | ExecStart=/usr/bin/pg_reloaded start --config="/etc/pg_reloaded/pg_reloaded.yml" 9 | ExecReload=/bin/kill -HUP $MAINPID 10 | Restart=on-failure 11 | KillSignal=SIGTERM 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /config/supervisor.conf: -------------------------------------------------------------------------------- 1 | [program:pg_reloaded] 2 | command=/usr/local/pg_reloaded start --config=/etc/pg_reloaded/pg_reloaded.yml 3 | -------------------------------------------------------------------------------- /cron/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 Rob Figueiredo 2 | All Rights Reserved. 3 | 4 | MIT LICENSE 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /cron/constantdelay.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import "time" 4 | 5 | // ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes". 6 | // It does not support jobs more frequent than once a second. 7 | type ConstantDelaySchedule struct { 8 | Delay time.Duration 9 | } 10 | 11 | // Every returns a crontab Schedule that activates once every duration. 12 | // Delays of less than a second are not supported (will round up to 1 second). 13 | // Any fields less than a Second are truncated. 14 | func Every(duration time.Duration) ConstantDelaySchedule { 15 | if duration < time.Second { 16 | duration = time.Second 17 | } 18 | return ConstantDelaySchedule{ 19 | Delay: duration - time.Duration(duration.Nanoseconds())%time.Second, 20 | } 21 | } 22 | 23 | // Next returns the next time this should be run. 24 | // This rounds so that the next activation time will be on the second. 25 | func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time { 26 | return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond) 27 | } 28 | -------------------------------------------------------------------------------- /cron/constantdelay_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestConstantDelayNext(t *testing.T) { 9 | tests := []struct { 10 | time string 11 | delay time.Duration 12 | expected string 13 | }{ 14 | // Simple cases 15 | {"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"}, 16 | {"Mon Jul 9 14:59 2012", 15 * time.Minute, "Mon Jul 9 15:14 2012"}, 17 | {"Mon Jul 9 14:59:59 2012", 15 * time.Minute, "Mon Jul 9 15:14:59 2012"}, 18 | 19 | // Wrap around hours 20 | {"Mon Jul 9 15:45 2012", 35 * time.Minute, "Mon Jul 9 16:20 2012"}, 21 | 22 | // Wrap around days 23 | {"Mon Jul 9 23:46 2012", 14 * time.Minute, "Tue Jul 10 00:00 2012"}, 24 | {"Mon Jul 9 23:45 2012", 35 * time.Minute, "Tue Jul 10 00:20 2012"}, 25 | {"Mon Jul 9 23:35:51 2012", 44*time.Minute + 24*time.Second, "Tue Jul 10 00:20:15 2012"}, 26 | {"Mon Jul 9 23:35:51 2012", 25*time.Hour + 44*time.Minute + 24*time.Second, "Thu Jul 11 01:20:15 2012"}, 27 | 28 | // Wrap around months 29 | {"Mon Jul 9 23:35 2012", 91*24*time.Hour + 25*time.Minute, "Thu Oct 9 00:00 2012"}, 30 | 31 | // Wrap around minute, hour, day, month, and year 32 | {"Mon Dec 31 23:59:45 2012", 15 * time.Second, "Tue Jan 1 00:00:00 2013"}, 33 | 34 | // Round to nearest second on the delay 35 | {"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"}, 36 | 37 | // Round up to 1 second if the duration is less. 38 | {"Mon Jul 9 14:45:00 2012", 15 * time.Millisecond, "Mon Jul 9 14:45:01 2012"}, 39 | 40 | // Round to nearest second when calculating the next time. 41 | {"Mon Jul 9 14:45:00.005 2012", 15 * time.Minute, "Mon Jul 9 15:00 2012"}, 42 | 43 | // Round to nearest second for both. 44 | {"Mon Jul 9 14:45:00.005 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"}, 45 | } 46 | 47 | for _, c := range tests { 48 | actual := Every(c.delay).Next(getTime(c.time)) 49 | expected := getTime(c.expected) 50 | if actual != expected { 51 | t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.delay, expected, actual) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cron/cron.go: -------------------------------------------------------------------------------- 1 | // This library implements a cron spec parser and runner. See the README for 2 | // more details. 3 | package cron 4 | 5 | import ( 6 | "sort" 7 | "time" 8 | ) 9 | 10 | // Cron keeps track of any number of entries, invoking the associated func as 11 | // specified by the schedule. It may be started, stopped, and the entries may 12 | // be inspected while running. 13 | type Cron struct { 14 | entries []*Entry 15 | stop chan struct{} 16 | add chan *Entry 17 | snapshot chan []*Entry 18 | running bool 19 | } 20 | 21 | // Job is an interface for submitted cron jobs. 22 | type Job interface { 23 | Run() 24 | } 25 | 26 | // The Schedule describes a job's duty cycle. 27 | type Schedule interface { 28 | // Return the next activation time, later than the given time. 29 | // Next is invoked initially, and then each time the job is run. 30 | Next(time.Time) time.Time 31 | } 32 | 33 | // Entry consists of a schedule and the func to execute on that schedule. 34 | type Entry struct { 35 | // The schedule on which this job should be run. 36 | Schedule Schedule 37 | 38 | // The next time the job will run. This is the zero time if Cron has not been 39 | // started or this entry's schedule is unsatisfiable 40 | Next time.Time 41 | 42 | // The last time this job was run. This is the zero time if the job has never 43 | // been run. 44 | Prev time.Time 45 | 46 | // The Job to run. 47 | Job Job 48 | } 49 | 50 | // byTime is a wrapper for sorting the entry array by time 51 | // (with zero time at the end). 52 | type byTime []*Entry 53 | 54 | func (s byTime) Len() int { return len(s) } 55 | func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 56 | func (s byTime) Less(i, j int) bool { 57 | // Two zero times should return false. 58 | // Otherwise, zero is "greater" than any other time. 59 | // (To sort it at the end of the list.) 60 | if s[i].Next.IsZero() { 61 | return false 62 | } 63 | if s[j].Next.IsZero() { 64 | return true 65 | } 66 | return s[i].Next.Before(s[j].Next) 67 | } 68 | 69 | // New returns a new Cron job runner. 70 | func New() *Cron { 71 | return &Cron{ 72 | entries: nil, 73 | add: make(chan *Entry), 74 | stop: make(chan struct{}), 75 | snapshot: make(chan []*Entry), 76 | running: false, 77 | } 78 | } 79 | 80 | // A wrapper that turns a func() into a cron.Job 81 | type FuncJob func() 82 | 83 | func (f FuncJob) Run() { f() } 84 | 85 | // AddFunc adds a func to the Cron to be run on the given schedule. 86 | func (c *Cron) AddFunc(spec string, cmd func()) error { 87 | return c.AddJob(spec, FuncJob(cmd)) 88 | } 89 | 90 | // AddJob adds a Job to the Cron to be run on the given schedule. 91 | func (c *Cron) AddJob(spec string, cmd Job) error { 92 | schedule, err := Parse(spec) 93 | if err != nil { 94 | return err 95 | } 96 | c.Schedule(schedule, cmd) 97 | return nil 98 | } 99 | 100 | // AddTimezonSensitiveJob adds a Job to the Cron that will be executed in 101 | // respect to a particular timezone on the given schedule. 102 | func (c *Cron) AddTimezoneSensitiveJob(spec, timezone string, cmd Job) error { 103 | schedule, err := Parse(spec) 104 | if err != nil { 105 | return err 106 | } 107 | tzSchedule, err := wrapSchedulerInTimezone(schedule, timezone) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | c.Schedule(tzSchedule, cmd) 113 | return nil 114 | } 115 | 116 | // Schedule adds a Job to the Cron to be run on the given schedule. 117 | func (c *Cron) Schedule(schedule Schedule, cmd Job) { 118 | entry := &Entry{ 119 | Schedule: schedule, 120 | Job: cmd, 121 | } 122 | if !c.running { 123 | c.entries = append(c.entries, entry) 124 | return 125 | } 126 | 127 | c.add <- entry 128 | } 129 | 130 | // Entries returns a snapshot of the cron entries. 131 | func (c *Cron) Entries() []*Entry { 132 | if c.running { 133 | c.snapshot <- nil 134 | x := <-c.snapshot 135 | return x 136 | } 137 | return c.entrySnapshot() 138 | } 139 | 140 | // Start the cron scheduler in its own go-routine. 141 | func (c *Cron) Start() { 142 | c.running = true 143 | go c.run() 144 | } 145 | 146 | // Run the scheduler.. this is private just due to the need to synchronize 147 | // access to the 'running' state variable. 148 | func (c *Cron) run() { 149 | // Figure out the next activation times for each entry. 150 | now := time.Now().Local() 151 | for _, entry := range c.entries { 152 | entry.Next = entry.Schedule.Next(now) 153 | } 154 | 155 | for { 156 | // Determine the next entry to run. 157 | sort.Sort(byTime(c.entries)) 158 | 159 | var effective time.Time 160 | if len(c.entries) == 0 || c.entries[0].Next.IsZero() { 161 | // If there are no entries yet, just sleep - it still handles new entries 162 | // and stop requests. 163 | effective = now.AddDate(10, 0, 0) 164 | } else { 165 | effective = c.entries[0].Next 166 | } 167 | 168 | select { 169 | case now = <-time.After(effective.Sub(now)): 170 | // Run every entry whose next time was this effective time. 171 | for _, e := range c.entries { 172 | if e.Next != effective { 173 | break 174 | } 175 | go e.Job.Run() 176 | e.Prev = e.Next 177 | e.Next = e.Schedule.Next(effective) 178 | } 179 | continue 180 | 181 | case newEntry := <-c.add: 182 | c.entries = append(c.entries, newEntry) 183 | newEntry.Next = newEntry.Schedule.Next(now) 184 | 185 | case <-c.snapshot: 186 | c.snapshot <- c.entrySnapshot() 187 | 188 | case <-c.stop: 189 | return 190 | } 191 | 192 | // 'now' should be updated after newEntry and snapshot cases. 193 | now = time.Now().Local() 194 | } 195 | } 196 | 197 | // Stop the cron scheduler. 198 | func (c *Cron) Stop() { 199 | c.stop <- struct{}{} 200 | c.running = false 201 | } 202 | 203 | // entrySnapshot returns a copy of the current cron entry list. 204 | func (c *Cron) entrySnapshot() []*Entry { 205 | entries := []*Entry{} 206 | for _, e := range c.entries { 207 | entries = append(entries, &Entry{ 208 | Schedule: e.Schedule, 209 | Next: e.Next, 210 | Prev: e.Prev, 211 | Job: e.Job, 212 | }) 213 | } 214 | return entries 215 | } 216 | -------------------------------------------------------------------------------- /cron/cron_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | // Many tests schedule a job for every second, and then wait at most a second 11 | // for it to run. This amount is just slightly larger than 1 second to 12 | // compensate for a few milliseconds of runtime. 13 | const ONE_SECOND = 1*time.Second + 10*time.Millisecond 14 | 15 | // Start and stop cron with no entries. 16 | func TestNoEntries(t *testing.T) { 17 | cron := New() 18 | cron.Start() 19 | 20 | select { 21 | case <-time.After(ONE_SECOND): 22 | t.FailNow() 23 | case <-stop(cron): 24 | } 25 | } 26 | 27 | // Start, stop, then add an entry. Verify entry doesn't run. 28 | func TestStopCausesJobsToNotRun(t *testing.T) { 29 | wg := &sync.WaitGroup{} 30 | wg.Add(1) 31 | 32 | cron := New() 33 | cron.Start() 34 | cron.Stop() 35 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 36 | 37 | select { 38 | case <-time.After(ONE_SECOND): 39 | // No job ran! 40 | case <-wait(wg): 41 | t.FailNow() 42 | } 43 | } 44 | 45 | // Add a job, start cron, expect it runs. 46 | func TestAddBeforeRunning(t *testing.T) { 47 | wg := &sync.WaitGroup{} 48 | wg.Add(1) 49 | 50 | cron := New() 51 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 52 | cron.Start() 53 | defer cron.Stop() 54 | 55 | // Give cron 2 seconds to run our job (which is always activated). 56 | select { 57 | case <-time.After(ONE_SECOND): 58 | t.FailNow() 59 | case <-wait(wg): 60 | } 61 | } 62 | 63 | // Start cron, add a job, expect it runs. 64 | func TestAddWhileRunning(t *testing.T) { 65 | wg := &sync.WaitGroup{} 66 | wg.Add(1) 67 | 68 | cron := New() 69 | cron.Start() 70 | defer cron.Stop() 71 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 72 | 73 | select { 74 | case <-time.After(ONE_SECOND): 75 | t.FailNow() 76 | case <-wait(wg): 77 | } 78 | } 79 | 80 | // Test timing with Entries. 81 | func TestSnapshotEntries(t *testing.T) { 82 | wg := &sync.WaitGroup{} 83 | wg.Add(1) 84 | 85 | cron := New() 86 | cron.AddFunc("@every 2s", func() { wg.Done() }) 87 | cron.Start() 88 | defer cron.Stop() 89 | 90 | // Cron should fire in 2 seconds. After 1 second, call Entries. 91 | select { 92 | case <-time.After(ONE_SECOND): 93 | cron.Entries() 94 | } 95 | 96 | // Even though Entries was called, the cron should fire at the 2 second mark. 97 | select { 98 | case <-time.After(ONE_SECOND): 99 | t.FailNow() 100 | case <-wait(wg): 101 | } 102 | 103 | } 104 | 105 | // Test that the entries are correctly sorted. 106 | // Add a bunch of long-in-the-future entries, and an immediate entry, and ensure 107 | // that the immediate entry runs immediately. 108 | // Also: Test that multiple jobs run in the same instant. 109 | func TestMultipleEntries(t *testing.T) { 110 | wg := &sync.WaitGroup{} 111 | wg.Add(2) 112 | 113 | cron := New() 114 | cron.AddFunc("0 0 0 1 1 ?", func() {}) 115 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 116 | cron.AddFunc("0 0 0 31 12 ?", func() {}) 117 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 118 | 119 | cron.Start() 120 | defer cron.Stop() 121 | 122 | select { 123 | case <-time.After(ONE_SECOND): 124 | t.FailNow() 125 | case <-wait(wg): 126 | } 127 | } 128 | 129 | // Test running the same job twice. 130 | func TestRunningJobTwice(t *testing.T) { 131 | wg := &sync.WaitGroup{} 132 | wg.Add(2) 133 | 134 | cron := New() 135 | cron.AddFunc("0 0 0 1 1 ?", func() {}) 136 | cron.AddFunc("0 0 0 31 12 ?", func() {}) 137 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 138 | 139 | cron.Start() 140 | defer cron.Stop() 141 | 142 | select { 143 | case <-time.After(2 * ONE_SECOND): 144 | t.FailNow() 145 | case <-wait(wg): 146 | } 147 | } 148 | 149 | func TestRunningMultipleSchedules(t *testing.T) { 150 | wg := &sync.WaitGroup{} 151 | wg.Add(2) 152 | 153 | cron := New() 154 | cron.AddFunc("0 0 0 1 1 ?", func() {}) 155 | cron.AddFunc("0 0 0 31 12 ?", func() {}) 156 | cron.AddFunc("* * * * * ?", func() { wg.Done() }) 157 | cron.Schedule(Every(time.Minute), FuncJob(func() {})) 158 | cron.Schedule(Every(time.Second), FuncJob(func() { wg.Done() })) 159 | cron.Schedule(Every(time.Hour), FuncJob(func() {})) 160 | 161 | cron.Start() 162 | defer cron.Stop() 163 | 164 | select { 165 | case <-time.After(2 * ONE_SECOND): 166 | t.FailNow() 167 | case <-wait(wg): 168 | } 169 | } 170 | 171 | // Test that the cron is run in the local time zone (as opposed to UTC). 172 | func TestLocalTimezone(t *testing.T) { 173 | wg := &sync.WaitGroup{} 174 | wg.Add(1) 175 | 176 | now := time.Now().Local() 177 | spec := fmt.Sprintf("%d %d %d %d %d ?", 178 | now.Second()+1, now.Minute(), now.Hour(), now.Day(), now.Month()) 179 | 180 | cron := New() 181 | cron.AddFunc(spec, func() { wg.Done() }) 182 | cron.Start() 183 | defer cron.Stop() 184 | 185 | select { 186 | case <-time.After(ONE_SECOND): 187 | t.FailNow() 188 | case <-wait(wg): 189 | } 190 | } 191 | 192 | type testJob struct { 193 | wg *sync.WaitGroup 194 | name string 195 | } 196 | 197 | func (t testJob) Run() { 198 | t.wg.Done() 199 | } 200 | 201 | // Simple test using Runnables. 202 | func TestJob(t *testing.T) { 203 | wg := &sync.WaitGroup{} 204 | wg.Add(1) 205 | 206 | cron := New() 207 | cron.AddJob("0 0 0 30 Feb ?", testJob{wg, "job0"}) 208 | cron.AddJob("0 0 0 1 1 ?", testJob{wg, "job1"}) 209 | cron.AddJob("* * * * * ?", testJob{wg, "job2"}) 210 | cron.AddJob("1 0 0 1 1 ?", testJob{wg, "job3"}) 211 | cron.Schedule(Every(5*time.Second+5*time.Nanosecond), testJob{wg, "job4"}) 212 | cron.Schedule(Every(5*time.Minute), testJob{wg, "job5"}) 213 | 214 | cron.Start() 215 | defer cron.Stop() 216 | 217 | select { 218 | case <-time.After(ONE_SECOND): 219 | t.FailNow() 220 | case <-wait(wg): 221 | } 222 | 223 | // Ensure the entries are in the right order. 224 | expecteds := []string{"job2", "job4", "job5", "job1", "job3", "job0"} 225 | 226 | var actuals []string 227 | for _, entry := range cron.Entries() { 228 | actuals = append(actuals, entry.Job.(testJob).name) 229 | } 230 | 231 | for i, expected := range expecteds { 232 | if actuals[i] != expected { 233 | t.Errorf("Jobs not in the right order. (expected) %s != %s (actual)", expecteds, actuals) 234 | t.FailNow() 235 | } 236 | } 237 | } 238 | 239 | func wait(wg *sync.WaitGroup) chan bool { 240 | ch := make(chan bool) 241 | go func() { 242 | wg.Wait() 243 | ch <- true 244 | }() 245 | return ch 246 | } 247 | 248 | func stop(cron *Cron) chan bool { 249 | ch := make(chan bool) 250 | go func() { 251 | cron.Stop() 252 | ch <- true 253 | }() 254 | return ch 255 | } 256 | -------------------------------------------------------------------------------- /cron/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package cron implements a cron spec parser and job runner. 3 | 4 | Usage 5 | 6 | Callers may register Funcs to be invoked on a given schedule. Cron will run 7 | them in their own goroutines. 8 | 9 | c := cron.New() 10 | c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") }) 11 | c.AddFunc("@hourly", func() { fmt.Println("Every hour") }) 12 | c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") }) 13 | c.Start() 14 | .. 15 | // Funcs are invoked in their own goroutine, asynchronously. 16 | ... 17 | // Funcs may also be added to a running Cron 18 | c.AddFunc("@daily", func() { fmt.Println("Every day") }) 19 | .. 20 | // Inspect the cron job entries' next and previous run times. 21 | inspect(c.Entries()) 22 | .. 23 | c.Stop() // Stop the scheduler (does not stop any jobs already running). 24 | 25 | CRON Expression Format 26 | 27 | A cron expression represents a set of times, using 6 space-separated fields. 28 | 29 | Field name | Mandatory? | Allowed values | Allowed special characters 30 | ---------- | ---------- | -------------- | -------------------------- 31 | Seconds | Yes | 0-59 | * / , - 32 | Minutes | Yes | 0-59 | * / , - 33 | Hours | Yes | 0-23 | * / , - 34 | Day of month | Yes | 1-31 | * / , - ? 35 | Month | Yes | 1-12 or JAN-DEC | * / , - 36 | Day of week | Yes | 0-6 or SUN-SAT | * / , - ? 37 | 38 | Note: Month and Day-of-week field values are case insensitive. "SUN", "Sun", 39 | and "sun" are equally accepted. 40 | 41 | Special Characters 42 | 43 | Asterisk ( * ) 44 | 45 | The asterisk indicates that the cron expression will match for all values of the 46 | field; e.g., using an asterisk in the 5th field (month) would indicate every 47 | month. 48 | 49 | Slash ( / ) 50 | 51 | Slashes are used to describe increments of ranges. For example 3-59/15 in the 52 | 1st field (minutes) would indicate the 3rd minute of the hour and every 15 53 | minutes thereafter. The form "*\/..." is equivalent to the form "first-last/...", 54 | that is, an increment over the largest possible range of the field. The form 55 | "N/..." is accepted as meaning "N-MAX/...", that is, starting at N, use the 56 | increment until the end of that specific range. It does not wrap around. 57 | 58 | Comma ( , ) 59 | 60 | Commas are used to separate items of a list. For example, using "MON,WED,FRI" in 61 | the 5th field (day of week) would mean Mondays, Wednesdays and Fridays. 62 | 63 | Hyphen ( - ) 64 | 65 | Hyphens are used to define ranges. For example, 9-17 would indicate every 66 | hour between 9am and 5pm inclusive. 67 | 68 | Question mark ( ? ) 69 | 70 | Question mark may be used instead of '*' for leaving either day-of-month or 71 | day-of-week blank. 72 | 73 | Predefined schedules 74 | 75 | You may use one of several pre-defined schedules in place of a cron expression. 76 | 77 | Entry | Description | Equivalent To 78 | ----- | ----------- | ------------- 79 | @yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 * 80 | @monthly | Run once a month, midnight, first of month | 0 0 0 1 * * 81 | @weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0 82 | @daily (or @midnight) | Run once a day, midnight | 0 0 0 * * * 83 | @hourly | Run once an hour, beginning of hour | 0 0 * * * * 84 | @minutely | Run once a minute, beginning of minute | 0 * * * * * 85 | 86 | Intervals 87 | 88 | You may also schedule a job to execute at fixed intervals. This is supported by 89 | formatting the cron spec like this: 90 | 91 | @every 92 | 93 | where "duration" is a string accepted by time.ParseDuration 94 | (http://golang.org/pkg/time/#ParseDuration). 95 | 96 | For example, "@every 1h30m10s" would indicate a schedule that activates every 97 | 1 hour, 30 minutes, 10 seconds. 98 | 99 | Note: The interval does not take the job runtime into account. For example, 100 | if a job takes 3 minutes to run, and it is scheduled to run every 5 minutes, 101 | it will have only 2 minutes of idle time between each run. 102 | 103 | Fixed times 104 | 105 | You may also schedule a job to execute once and never more. This is supported by 106 | formatting the cron spec like this: 107 | 108 | @at 109 | 110 | Where "datetime" is a string accepted by time.Parse in RFC 3339/ISO 8601 format 111 | (https://golang.org/pkg/time/#Parse). 112 | 113 | For example, "@at 2018-01-02T15:04:00" would run the job on the specified date and time 114 | assuming UTC timezone. 115 | 116 | Time zones 117 | 118 | All interpretation and scheduling is done in the machine's local time zone (as 119 | provided by the Go time package (http://www.golang.org/pkg/time). 120 | 121 | Be aware that jobs scheduled during daylight-savings leap-ahead transitions will 122 | not be run! 123 | 124 | Thread safety 125 | 126 | Since the Cron service runs concurrently with the calling code, some amount of 127 | care must be taken to ensure proper synchronization. 128 | 129 | All cron methods are designed to be correctly synchronized as long as the caller 130 | ensures that invocations have a clear happens-before ordering between them. 131 | 132 | Implementation 133 | 134 | Cron entries are stored in an array, sorted by their next activation time. Cron 135 | sleeps until the next job is due to be run. 136 | 137 | Upon waking: 138 | - it runs each entry that is active on that second 139 | - it calculates the next run times for the jobs that were run 140 | - it re-sorts the array of entries by next activation time. 141 | - it goes to sleep until the soonest job. 142 | */ 143 | package cron 144 | -------------------------------------------------------------------------------- /cron/parser.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // Parse returns a new crontab schedule representing the given spec. 13 | // It returns a descriptive error if the spec is not valid. 14 | // 15 | // It accepts 16 | // - Full crontab specs, e.g. "* * * * * ?" 17 | // - Descriptors, e.g. "@midnight", "@every 1h30m" 18 | func Parse(spec string) (_ Schedule, err error) { 19 | // Convert panics into errors 20 | defer func() { 21 | if recovered := recover(); recovered != nil { 22 | err = fmt.Errorf("%v", recovered) 23 | } 24 | }() 25 | 26 | if spec[0] == '@' { 27 | return parseDescriptor(spec), nil 28 | } 29 | 30 | // Split on whitespace. We require 5 or 6 fields. 31 | // (second) (minute) (hour) (day of month) (month) (day of week, optional) 32 | fields := strings.Fields(spec) 33 | if len(fields) != 5 && len(fields) != 6 { 34 | log.Panicf("Expected 5 or 6 fields, found %d: %s", len(fields), spec) 35 | } 36 | 37 | // If a sixth field is not provided (DayOfWeek), then it is equivalent to star. 38 | if len(fields) == 5 { 39 | fields = append(fields, "*") 40 | } 41 | 42 | schedule := &SpecSchedule{ 43 | Second: getField(fields[0], seconds), 44 | Minute: getField(fields[1], minutes), 45 | Hour: getField(fields[2], hours), 46 | Dom: getField(fields[3], dom), 47 | Month: getField(fields[4], months), 48 | Dow: getField(fields[5], dow), 49 | } 50 | 51 | return schedule, nil 52 | } 53 | 54 | // getField returns an Int with the bits set representing all of the times that 55 | // the field represents. A "field" is a comma-separated list of "ranges". 56 | func getField(field string, r bounds) uint64 { 57 | // list = range {"," range} 58 | var bits uint64 59 | ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) 60 | for _, expr := range ranges { 61 | bits |= getRange(expr, r) 62 | } 63 | return bits 64 | } 65 | 66 | // getRange returns the bits indicated by the given expression: 67 | // number | number "-" number [ "/" number ] 68 | func getRange(expr string, r bounds) uint64 { 69 | 70 | var ( 71 | start, end, step uint 72 | rangeAndStep = strings.Split(expr, "/") 73 | lowAndHigh = strings.Split(rangeAndStep[0], "-") 74 | singleDigit = len(lowAndHigh) == 1 75 | ) 76 | 77 | var extra_star uint64 78 | if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { 79 | start = r.min 80 | end = r.max 81 | extra_star = starBit 82 | } else { 83 | start = parseIntOrName(lowAndHigh[0], r.names) 84 | switch len(lowAndHigh) { 85 | case 1: 86 | end = start 87 | case 2: 88 | end = parseIntOrName(lowAndHigh[1], r.names) 89 | default: 90 | log.Panicf("Too many hyphens: %s", expr) 91 | } 92 | } 93 | 94 | switch len(rangeAndStep) { 95 | case 1: 96 | step = 1 97 | case 2: 98 | step = mustParseInt(rangeAndStep[1]) 99 | 100 | // Special handling: "N/step" means "N-max/step". 101 | if singleDigit { 102 | end = r.max 103 | } 104 | default: 105 | log.Panicf("Too many slashes: %s", expr) 106 | } 107 | 108 | if start < r.min { 109 | log.Panicf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr) 110 | } 111 | if end > r.max { 112 | log.Panicf("End of range (%d) above maximum (%d): %s", end, r.max, expr) 113 | } 114 | if start > end { 115 | log.Panicf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr) 116 | } 117 | 118 | return getBits(start, end, step) | extra_star 119 | } 120 | 121 | // parseIntOrName returns the (possibly-named) integer contained in expr. 122 | func parseIntOrName(expr string, names map[string]uint) uint { 123 | if names != nil { 124 | if namedInt, ok := names[strings.ToLower(expr)]; ok { 125 | return namedInt 126 | } 127 | } 128 | return mustParseInt(expr) 129 | } 130 | 131 | // mustParseInt parses the given expression as an int or panics. 132 | func mustParseInt(expr string) uint { 133 | num, err := strconv.Atoi(expr) 134 | if err != nil { 135 | log.Panicf("Failed to parse int from %s: %s", expr, err) 136 | } 137 | if num < 0 { 138 | log.Panicf("Negative number (%d) not allowed: %s", num, expr) 139 | } 140 | 141 | return uint(num) 142 | } 143 | 144 | // getBits sets all bits in the range [min, max], modulo the given step size. 145 | func getBits(min, max, step uint) uint64 { 146 | var bits uint64 147 | 148 | // If step is 1, use shifts. 149 | if step == 1 { 150 | return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min) 151 | } 152 | 153 | // Else, use a simple loop. 154 | for i := min; i <= max; i += step { 155 | bits |= 1 << i 156 | } 157 | return bits 158 | } 159 | 160 | // all returns all bits within the given bounds. (plus the star bit) 161 | func all(r bounds) uint64 { 162 | return getBits(r.min, r.max, 1) | starBit 163 | } 164 | 165 | // parseDescriptor returns a pre-defined schedule for the expression, or panics 166 | // if none matches. 167 | func parseDescriptor(spec string) Schedule { 168 | switch spec { 169 | case "@yearly", "@annually": 170 | return &SpecSchedule{ 171 | Second: 1 << seconds.min, 172 | Minute: 1 << minutes.min, 173 | Hour: 1 << hours.min, 174 | Dom: 1 << dom.min, 175 | Month: 1 << months.min, 176 | Dow: all(dow), 177 | } 178 | 179 | case "@monthly": 180 | return &SpecSchedule{ 181 | Second: 1 << seconds.min, 182 | Minute: 1 << minutes.min, 183 | Hour: 1 << hours.min, 184 | Dom: 1 << dom.min, 185 | Month: all(months), 186 | Dow: all(dow), 187 | } 188 | 189 | case "@weekly": 190 | return &SpecSchedule{ 191 | Second: 1 << seconds.min, 192 | Minute: 1 << minutes.min, 193 | Hour: 1 << hours.min, 194 | Dom: all(dom), 195 | Month: all(months), 196 | Dow: 1 << dow.min, 197 | } 198 | 199 | case "@daily", "@midnight": 200 | return &SpecSchedule{ 201 | Second: 1 << seconds.min, 202 | Minute: 1 << minutes.min, 203 | Hour: 1 << hours.min, 204 | Dom: all(dom), 205 | Month: all(months), 206 | Dow: all(dow), 207 | } 208 | 209 | case "@hourly": 210 | return &SpecSchedule{ 211 | Second: 1 << seconds.min, 212 | Minute: 1 << minutes.min, 213 | Hour: all(hours), 214 | Dom: all(dom), 215 | Month: all(months), 216 | Dow: all(dow), 217 | } 218 | 219 | case "@minutely": 220 | return &SpecSchedule{ 221 | Second: 1 << seconds.min, 222 | Minute: all(minutes), 223 | Hour: all(hours), 224 | Dom: all(dom), 225 | Month: all(months), 226 | Dow: all(dow), 227 | } 228 | } 229 | 230 | const every = "@every " 231 | if strings.HasPrefix(spec, every) { 232 | duration, err := time.ParseDuration(spec[len(every):]) 233 | if err != nil { 234 | log.Panicf("Failed to parse duration %s: %s", spec, err) 235 | } 236 | return Every(duration) 237 | } 238 | 239 | const at = "@at " 240 | if strings.HasPrefix(spec, at) { 241 | date, err := time.Parse(time.RFC3339, spec[len(at):]) 242 | if err != nil { 243 | log.Panicf("Failed to parse date %s: %s", spec, err) 244 | } 245 | return At(date) 246 | } 247 | 248 | log.Panicf("Unrecognized descriptor: %s", spec) 249 | return nil 250 | } 251 | -------------------------------------------------------------------------------- /cron/parser_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestRange(t *testing.T) { 10 | ranges := []struct { 11 | expr string 12 | min, max uint 13 | expected uint64 14 | }{ 15 | {"5", 0, 7, 1 << 5}, 16 | {"0", 0, 7, 1 << 0}, 17 | {"7", 0, 7, 1 << 7}, 18 | 19 | {"5-5", 0, 7, 1 << 5}, 20 | {"5-6", 0, 7, 1<<5 | 1<<6}, 21 | {"5-7", 0, 7, 1<<5 | 1<<6 | 1<<7}, 22 | 23 | {"5-6/2", 0, 7, 1 << 5}, 24 | {"5-7/2", 0, 7, 1<<5 | 1<<7}, 25 | {"5-7/1", 0, 7, 1<<5 | 1<<6 | 1<<7}, 26 | 27 | {"*", 1, 3, 1<<1 | 1<<2 | 1<<3 | starBit}, 28 | {"*/2", 1, 3, 1<<1 | 1<<3 | starBit}, 29 | } 30 | 31 | for _, c := range ranges { 32 | actual := getRange(c.expr, bounds{c.min, c.max, nil}) 33 | if actual != c.expected { 34 | t.Errorf("%s => (expected) %d != %d (actual)", c.expr, c.expected, actual) 35 | } 36 | } 37 | } 38 | 39 | func TestField(t *testing.T) { 40 | fields := []struct { 41 | expr string 42 | min, max uint 43 | expected uint64 44 | }{ 45 | {"5", 1, 7, 1 << 5}, 46 | {"5,6", 1, 7, 1<<5 | 1<<6}, 47 | {"5,6,7", 1, 7, 1<<5 | 1<<6 | 1<<7}, 48 | {"1,5-7/2,3", 1, 7, 1<<1 | 1<<5 | 1<<7 | 1<<3}, 49 | } 50 | 51 | for _, c := range fields { 52 | actual := getField(c.expr, bounds{c.min, c.max, nil}) 53 | if actual != c.expected { 54 | t.Errorf("%s => (expected) %d != %d (actual)", c.expr, c.expected, actual) 55 | } 56 | } 57 | } 58 | 59 | func TestBits(t *testing.T) { 60 | allBits := []struct { 61 | r bounds 62 | expected uint64 63 | }{ 64 | {minutes, 0xfffffffffffffff}, // 0-59: 60 ones 65 | {hours, 0xffffff}, // 0-23: 24 ones 66 | {dom, 0xfffffffe}, // 1-31: 31 ones, 1 zero 67 | {months, 0x1ffe}, // 1-12: 12 ones, 1 zero 68 | {dow, 0x7f}, // 0-6: 7 ones 69 | } 70 | 71 | for _, c := range allBits { 72 | actual := all(c.r) // all() adds the starBit, so compensate for that.. 73 | if c.expected|starBit != actual { 74 | t.Errorf("%d-%d/%d => (expected) %b != %b (actual)", 75 | c.r.min, c.r.max, 1, c.expected|starBit, actual) 76 | } 77 | } 78 | 79 | bits := []struct { 80 | min, max, step uint 81 | expected uint64 82 | }{ 83 | 84 | {0, 0, 1, 0x1}, 85 | {1, 1, 1, 0x2}, 86 | {1, 5, 2, 0x2a}, // 101010 87 | {1, 4, 2, 0xa}, // 1010 88 | } 89 | 90 | for _, c := range bits { 91 | actual := getBits(c.min, c.max, c.step) 92 | if c.expected != actual { 93 | t.Errorf("%d-%d/%d => (expected) %b != %b (actual)", 94 | c.min, c.max, c.step, c.expected, actual) 95 | } 96 | } 97 | } 98 | 99 | func TestSpecSchedule(t *testing.T) { 100 | entries := []struct { 101 | expr string 102 | expected Schedule 103 | }{ 104 | {"* 5 * * * *", &SpecSchedule{all(seconds), 1 << 5, all(hours), all(dom), all(months), all(dow)}}, 105 | {"@every 5m", ConstantDelaySchedule{time.Duration(5) * time.Minute}}, 106 | {"@at 2018-01-02T15:04:00Z", SimpleSchedule{time.Date(2018, time.January, 2, 15, 4, 0, 0, time.UTC)}}, 107 | {"@at 2019-02-04T09:20:00+06:00", SimpleSchedule{time.Date(2019, time.February, 4, 9, 20, 0, 0, time.FixedZone("", 21600))}}, 108 | } 109 | 110 | for _, c := range entries { 111 | actual, err := Parse(c.expr) 112 | if err != nil { 113 | t.Error(err) 114 | } 115 | if !reflect.DeepEqual(actual, c.expected) { 116 | t.Errorf("%s => (expected) %v != %v (actual)", c.expr, c.expected, actual) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /cron/simple.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // SimpleDelaySchedule represents a simple non recurring duration. 8 | type SimpleSchedule struct { 9 | Date time.Time 10 | } 11 | 12 | // Just store the given time for this schedule. 13 | func At(date time.Time) SimpleSchedule { 14 | return SimpleSchedule{ 15 | Date: date, 16 | } 17 | } 18 | 19 | // Next conforms to the Schedule interface but this kind of jobs 20 | // doesn't need to be run more than once, so it doesn't return a new date but the existing one. 21 | func (schedule SimpleSchedule) Next(t time.Time) time.Time { 22 | // If the date set is after the reference time return it 23 | // if it's before, return a virtually infinite sleep date 24 | // so do nothing. 25 | if schedule.Date.After(t) { 26 | return schedule.Date 27 | } 28 | return t.AddDate(10, 0, 0) 29 | } 30 | -------------------------------------------------------------------------------- /cron/simple_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestSimpleNext(t *testing.T) { 9 | tests := []struct { 10 | time string 11 | date string 12 | expected string 13 | }{ 14 | // Simple cases 15 | {"2012-07-09T14:45:00Z", "2012-07-09T15:00:00Z", "2012-07-09T15:00:00Z"}, 16 | {"2012-07-09T14:45:00Z", "2012-07-05T13:00:00Z", "2022-07-09T14:45:00Z"}, 17 | } 18 | 19 | for _, c := range tests { 20 | now, _ := time.Parse(time.RFC3339, c.time) 21 | date, _ := time.Parse(time.RFC3339, c.date) 22 | actual := At(date).Next(now) 23 | expected, _ := time.Parse(time.RFC3339, c.expected) 24 | 25 | if !actual.After(now) { 26 | t.Errorf("%s, \"%s\": (expected) %v after %v (actual)", c.time, c.date, expected, actual) 27 | } 28 | 29 | if actual != expected { 30 | t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.date, expected, actual) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cron/spec.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import "time" 4 | 5 | // SpecSchedule specifies a duty cycle (to the second granularity), based on a 6 | // traditional crontab specification. It is computed initially and stored as bit sets. 7 | type SpecSchedule struct { 8 | Second, Minute, Hour, Dom, Month, Dow uint64 9 | } 10 | 11 | // bounds provides a range of acceptable values (plus a map of name to value). 12 | type bounds struct { 13 | min, max uint 14 | names map[string]uint 15 | } 16 | 17 | // The bounds for each field. 18 | var ( 19 | seconds = bounds{0, 59, nil} 20 | minutes = bounds{0, 59, nil} 21 | hours = bounds{0, 23, nil} 22 | dom = bounds{1, 31, nil} 23 | months = bounds{1, 12, map[string]uint{ 24 | "jan": 1, 25 | "feb": 2, 26 | "mar": 3, 27 | "apr": 4, 28 | "may": 5, 29 | "jun": 6, 30 | "jul": 7, 31 | "aug": 8, 32 | "sep": 9, 33 | "oct": 10, 34 | "nov": 11, 35 | "dec": 12, 36 | }} 37 | dow = bounds{0, 6, map[string]uint{ 38 | "sun": 0, 39 | "mon": 1, 40 | "tue": 2, 41 | "wed": 3, 42 | "thu": 4, 43 | "fri": 5, 44 | "sat": 6, 45 | }} 46 | ) 47 | 48 | const ( 49 | // Set the top bit if a star was included in the expression. 50 | starBit = 1 << 63 51 | ) 52 | 53 | // Next returns the next time this schedule is activated, greater than the given 54 | // time. If no time can be found to satisfy the schedule, return the zero time. 55 | func (s *SpecSchedule) Next(t time.Time) time.Time { 56 | // General approach: 57 | // For Month, Day, Hour, Minute, Second: 58 | // Check if the time value matches. If yes, continue to the next field. 59 | // If the field doesn't match the schedule, then increment the field until it matches. 60 | // While incrementing the field, a wrap-around brings it back to the beginning 61 | // of the field list (since it is necessary to re-verify previous field 62 | // values) 63 | 64 | // Start at the earliest possible time (the upcoming second). 65 | t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond) 66 | 67 | // This flag indicates whether a field has been incremented. 68 | added := false 69 | 70 | // If no time is found within five years, return zero. 71 | yearLimit := t.Year() + 5 72 | 73 | WRAP: 74 | if t.Year() > yearLimit { 75 | return time.Time{} 76 | } 77 | 78 | // Find the first applicable month. 79 | // If it's this month, then do nothing. 80 | for 1< 0 152 | dowMatch bool = 1< 0 153 | ) 154 | 155 | if s.Dom&starBit > 0 || s.Dow&starBit > 0 { 156 | return domMatch && dowMatch 157 | } 158 | return domMatch || dowMatch 159 | } 160 | -------------------------------------------------------------------------------- /cron/spec_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestActivation(t *testing.T) { 9 | tests := []struct { 10 | time, spec string 11 | expected bool 12 | }{ 13 | // Every fifteen minutes. 14 | {"Mon Jul 9 15:00 2012", "0 0/15 * * *", true}, 15 | {"Mon Jul 9 15:45 2012", "0 0/15 * * *", true}, 16 | {"Mon Jul 9 15:40 2012", "0 0/15 * * *", false}, 17 | 18 | // Every fifteen minutes, starting at 5 minutes. 19 | {"Mon Jul 9 15:05 2012", "0 5/15 * * *", true}, 20 | {"Mon Jul 9 15:20 2012", "0 5/15 * * *", true}, 21 | {"Mon Jul 9 15:50 2012", "0 5/15 * * *", true}, 22 | 23 | // Named months 24 | {"Sun Jul 15 15:00 2012", "0 0/15 * * Jul", true}, 25 | {"Sun Jul 15 15:00 2012", "0 0/15 * * Jun", false}, 26 | 27 | // Everything set. 28 | {"Sun Jul 15 08:30 2012", "0 30 08 ? Jul Sun", true}, 29 | {"Sun Jul 15 08:30 2012", "0 30 08 15 Jul ?", true}, 30 | {"Mon Jul 16 08:30 2012", "0 30 08 ? Jul Sun", false}, 31 | {"Mon Jul 16 08:30 2012", "0 30 08 15 Jul ?", false}, 32 | 33 | // Predefined schedules 34 | {"Mon Jul 9 15:00:00 2012", "@minutely", true}, 35 | {"Mon Jul 9 15:00:15 2012", "@minutely", false}, 36 | {"Mon Jul 9 15:00 2012", "@hourly", true}, 37 | {"Mon Jul 9 15:04 2012", "@hourly", false}, 38 | {"Mon Jul 9 15:00 2012", "@daily", false}, 39 | {"Mon Jul 9 00:00 2012", "@daily", true}, 40 | {"Mon Jul 9 00:00 2012", "@weekly", false}, 41 | {"Sun Jul 8 00:00 2012", "@weekly", true}, 42 | {"Sun Jul 8 01:00 2012", "@weekly", false}, 43 | {"Sun Jul 8 00:00 2012", "@monthly", false}, 44 | {"Sun Jul 1 00:00 2012", "@monthly", true}, 45 | 46 | // Test interaction of DOW and DOM. 47 | // If both are specified, then only one needs to match. 48 | {"Sun Jul 15 00:00 2012", "0 * * 1,15 * Sun", true}, 49 | {"Fri Jun 15 00:00 2012", "0 * * 1,15 * Sun", true}, 50 | {"Wed Aug 1 00:00 2012", "0 * * 1,15 * Sun", true}, 51 | 52 | // However, if one has a star, then both need to match. 53 | {"Sun Jul 15 00:00 2012", "0 * * * * Mon", false}, 54 | {"Sun Jul 15 00:00 2012", "0 * * */10 * Sun", false}, 55 | {"Mon Jul 9 00:00 2012", "0 * * 1,15 * *", false}, 56 | {"Sun Jul 15 00:00 2012", "0 * * 1,15 * *", true}, 57 | {"Sun Jul 15 00:00 2012", "0 * * */2 * Sun", true}, 58 | } 59 | 60 | for _, test := range tests { 61 | sched, err := Parse(test.spec) 62 | if err != nil { 63 | t.Error(err) 64 | continue 65 | } 66 | actual := sched.Next(getTime(test.time).Add(-1 * time.Second)) 67 | expected := getTime(test.time) 68 | if test.expected && expected != actual || !test.expected && expected == actual { 69 | t.Errorf("Fail evaluating %s on %s: (expected) %s != %s (actual)", 70 | test.spec, test.time, expected, actual) 71 | } 72 | } 73 | } 74 | 75 | func TestNext(t *testing.T) { 76 | runs := []struct { 77 | time, spec string 78 | expected string 79 | }{ 80 | // Simple cases 81 | {"Mon Jul 9 14:45 2012", "0 0/15 * * *", "Mon Jul 9 15:00 2012"}, 82 | {"Mon Jul 9 14:59 2012", "0 0/15 * * *", "Mon Jul 9 15:00 2012"}, 83 | {"Mon Jul 9 14:59:59 2012", "0 0/15 * * *", "Mon Jul 9 15:00 2012"}, 84 | 85 | // Wrap around hours 86 | {"Mon Jul 9 15:45 2012", "0 20-35/15 * * *", "Mon Jul 9 16:20 2012"}, 87 | 88 | // Wrap around days 89 | {"Mon Jul 9 23:46 2012", "0 */15 * * *", "Tue Jul 10 00:00 2012"}, 90 | {"Mon Jul 9 23:45 2012", "0 20-35/15 * * *", "Tue Jul 10 00:20 2012"}, 91 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * * *", "Tue Jul 10 00:20:15 2012"}, 92 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 * *", "Tue Jul 10 01:20:15 2012"}, 93 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 10-12 * *", "Tue Jul 10 10:20:15 2012"}, 94 | 95 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 */2 * *", "Thu Jul 11 01:20:15 2012"}, 96 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 * *", "Wed Jul 10 00:20:15 2012"}, 97 | {"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 Jul *", "Wed Jul 10 00:20:15 2012"}, 98 | 99 | // Wrap around months 100 | {"Mon Jul 9 23:35 2012", "0 0 0 9 Apr-Oct ?", "Thu Aug 9 00:00 2012"}, 101 | {"Mon Jul 9 23:35 2012", "0 0 0 */5 Apr,Aug,Oct Mon", "Mon Aug 6 00:00 2012"}, 102 | {"Mon Jul 9 23:35 2012", "0 0 0 */5 Oct Mon", "Mon Oct 1 00:00 2012"}, 103 | 104 | // Wrap around years 105 | {"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon", "Mon Feb 4 00:00 2013"}, 106 | {"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon/2", "Fri Feb 1 00:00 2013"}, 107 | 108 | // Wrap around minute, hour, day, month, and year 109 | {"Mon Dec 31 23:59:45 2012", "0 * * * * *", "Tue Jan 1 00:00:00 2013"}, 110 | 111 | // Leap year 112 | {"Mon Jul 9 23:35 2012", "0 0 0 29 Feb ?", "Mon Feb 29 00:00 2016"}, 113 | 114 | // Daylight savings time 2am EST (-5) -> 3am EDT (-4) 115 | {"2012-03-11T00:00:00-0500", "0 30 2 11 Mar ?", "2013-03-11T02:30:00-0400"}, 116 | 117 | // hourly job 118 | {"2012-03-11T00:00:00-0500", "0 0 * * * ?", "2012-03-11T01:00:00-0500"}, 119 | {"2012-03-11T01:00:00-0500", "0 0 * * * ?", "2012-03-11T03:00:00-0400"}, 120 | {"2012-03-11T03:00:00-0400", "0 0 * * * ?", "2012-03-11T04:00:00-0400"}, 121 | {"2012-03-11T04:00:00-0400", "0 0 * * * ?", "2012-03-11T05:00:00-0400"}, 122 | 123 | // 1am nightly job 124 | {"2012-03-11T00:00:00-0500", "0 0 1 * * ?", "2012-03-11T01:00:00-0500"}, 125 | {"2012-03-11T01:00:00-0500", "0 0 1 * * ?", "2012-03-12T01:00:00-0400"}, 126 | 127 | // 2am nightly job (skipped) 128 | {"2012-03-11T00:00:00-0500", "0 0 2 * * ?", "2012-03-12T02:00:00-0400"}, 129 | 130 | // Daylight savings time 2am EDT (-4) => 1am EST (-5) 131 | {"2012-11-04T00:00:00-0400", "0 30 2 04 Nov ?", "2012-11-04T02:30:00-0500"}, 132 | {"2012-11-04T01:45:00-0400", "0 30 1 04 Nov ?", "2012-11-04T01:30:00-0500"}, 133 | 134 | // hourly job 135 | {"2012-11-04T00:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0400"}, 136 | {"2012-11-04T01:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0500"}, 137 | {"2012-11-04T01:00:00-0500", "0 0 * * * ?", "2012-11-04T02:00:00-0500"}, 138 | 139 | // 1am nightly job (runs twice) 140 | {"2012-11-04T00:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0400"}, 141 | {"2012-11-04T01:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0500"}, 142 | {"2012-11-04T01:00:00-0500", "0 0 1 * * ?", "2012-11-05T01:00:00-0500"}, 143 | 144 | // 2am nightly job 145 | {"2012-11-04T00:00:00-0400", "0 0 2 * * ?", "2012-11-04T02:00:00-0500"}, 146 | {"2012-11-04T02:00:00-0500", "0 0 2 * * ?", "2012-11-05T02:00:00-0500"}, 147 | 148 | // 3am nightly job 149 | {"2012-11-04T00:00:00-0400", "0 0 3 * * ?", "2012-11-04T03:00:00-0500"}, 150 | {"2012-11-04T03:00:00-0500", "0 0 3 * * ?", "2012-11-05T03:00:00-0500"}, 151 | 152 | // Unsatisfiable 153 | {"Mon Jul 9 23:35 2012", "0 0 0 30 Feb ?", ""}, 154 | {"Mon Jul 9 23:35 2012", "0 0 0 31 Apr ?", ""}, 155 | } 156 | 157 | for _, c := range runs { 158 | sched, err := Parse(c.spec) 159 | if err != nil { 160 | t.Error(err) 161 | continue 162 | } 163 | actual := sched.Next(getTime(c.time)) 164 | expected := getTime(c.expected) 165 | if !actual.Equal(expected) { 166 | t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual) 167 | } 168 | } 169 | } 170 | 171 | func TestErrors(t *testing.T) { 172 | invalidSpecs := []string{ 173 | "xyz", 174 | "60 0 * * *", 175 | "0 60 * * *", 176 | "0 0 * * XYZ", 177 | } 178 | for _, spec := range invalidSpecs { 179 | _, err := Parse(spec) 180 | if err == nil { 181 | t.Error("expected an error parsing: ", spec) 182 | } 183 | } 184 | } 185 | 186 | func getTime(value string) time.Time { 187 | if value == "" { 188 | return time.Time{} 189 | } 190 | t, err := time.Parse("Mon Jan 2 15:04 2006", value) 191 | if err != nil { 192 | t, err = time.Parse("Mon Jan 2 15:04:05 2006", value) 193 | if err != nil { 194 | t, err = time.Parse("2006-01-02T15:04:05-0700", value) 195 | if err != nil { 196 | panic(err) 197 | } 198 | // Daylight savings time tests require location 199 | if ny, err := time.LoadLocation("America/New_York"); err == nil { 200 | t = t.In(ny) 201 | } 202 | } 203 | } 204 | 205 | return t 206 | } 207 | -------------------------------------------------------------------------------- /cron/tz.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Schedule wrapper with time zone awarness 8 | type timezoneAwareSchedule struct { 9 | underlyingSchedule Schedule 10 | targetedTimezone *time.Location 11 | } 12 | 13 | func (schedule *timezoneAwareSchedule) Next(t time.Time) time.Time { 14 | return schedule.underlyingSchedule.Next(t.In(schedule.targetedTimezone)) 15 | } 16 | 17 | // Wrap a schedule inside a timezoneAwareSchedule. timezone string 18 | // must be a supported timezone representation by the OS otherwise 19 | // will return an error. 20 | func wrapSchedulerInTimezone(schedule Schedule, timezone string) (*timezoneAwareSchedule, error) { 21 | if location, err := time.LoadLocation(timezone); err != nil { 22 | return nil, err 23 | } else { 24 | return &timezoneAwareSchedule{schedule, location}, nil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cron/tz_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestTimezoneAwareSchedule(t *testing.T) { 9 | timezone := "Europe/London" 10 | schedule, _ := Parse("0 13 15 * * *") 11 | now, _ := time.Parse(time.RFC3339, "2017-09-09T22:08:41+04:00") 12 | expt, _ := time.Parse(time.RFC3339, "2017-09-10T15:13:00+01:00") 13 | if tzSchedule, err := wrapSchedulerInTimezone(schedule, timezone); err != nil { 14 | t.Error(err) 15 | } else if act := tzSchedule.Next(now); !act.Equal(expt) { 16 | t.Error(act.Format(time.RFC3339)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nndi-oss/pg_reloaded 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/hashicorp/go-hclog v0.9.2 7 | github.com/mitchellh/go-homedir v1.1.0 8 | github.com/spf13/cobra v0.0.5 9 | github.com/spf13/viper v1.4.0 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 5 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 6 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 7 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 8 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 9 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 10 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 11 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 12 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 13 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 14 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 15 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 16 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 17 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 18 | github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 22 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 23 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 24 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 25 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 26 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 27 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 28 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 29 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 30 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 31 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 32 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 33 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 34 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 35 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 36 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 37 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 38 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 39 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 40 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 41 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 42 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 43 | github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 44 | github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 45 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 46 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 47 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 48 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 49 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 50 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 51 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 52 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 53 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 54 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 55 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 56 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 57 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 58 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 59 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 60 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 61 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 62 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 63 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 64 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 65 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 66 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 67 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 68 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 69 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 70 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 71 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 75 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 76 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 77 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 78 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 79 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 80 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 81 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 82 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 83 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 84 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 85 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 86 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 87 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 88 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 89 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 90 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 91 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 92 | github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= 93 | github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= 94 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 95 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 96 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 97 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 98 | github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 99 | github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= 100 | github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= 101 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 102 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 103 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 104 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 105 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 106 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 107 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 108 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 109 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 110 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 111 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 112 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 113 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 114 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 115 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 116 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 117 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 118 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 119 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 120 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 121 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 122 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 123 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 124 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 125 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 126 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 127 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 128 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 129 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 130 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 131 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 132 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 133 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 134 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 135 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 136 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 137 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 138 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 139 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 140 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 141 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 142 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 143 | google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 144 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 145 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 146 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 147 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 148 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 149 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 150 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 151 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 152 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 153 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 154 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | PG Reloaded By NNDI 20 | 22 | 35 | 38 | 42 | 46 | 47 | 57 | 58 | 76 | 78 | 79 | 81 | image/svg+xml 82 | 84 | PG Reloaded By NNDI 85 | 86 | 87 | 88 | 93 | 96 | 106 | 111 | PG Reloaded 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/nndi-oss/pg_reloaded/cmd" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | fmt.Println(err) 12 | os.Exit(1) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: "PG Reloaded Control Plane API" 4 | description: "An API to control a PG Reloaded instance" 5 | version: 0.1.0 6 | license: 7 | name: Apache 2.0 8 | url: https://www.apache.org/licenses/LICENSE-2.0.html 9 | paths: 10 | /environment: 11 | get: 12 | operationId: GetEnvironment 13 | summary: Get the environment variables for running pg_reloaded 14 | tags: 15 | - system 16 | responses: 17 | '200': 18 | description: Environment variables 19 | /servers: 20 | get: 21 | summary: Get all registered servers 22 | operationId: GetAllServers 23 | responses: 24 | '200': 25 | description: servers response 26 | content: 27 | application/json: 28 | schema: 29 | $ref : '#/components/schemas/Server' 30 | post: 31 | summary: Register a server on pg_reloaded 32 | operationId: RegisterServer 33 | requestBody: 34 | description: Server to add to the configuration 35 | required: true 36 | content: 37 | application/json: 38 | schema: 39 | $ref: '#/components/schemas/Server' 40 | responses: 41 | '201': 42 | description: server registered 43 | content: 44 | application/json: 45 | schema: 46 | $ref : '#/components/schemas/Server' 47 | /servers/{name}: 48 | get: 49 | summary: Get server with specified name, name is case sensitive 50 | operationId: GetServer 51 | parameters: 52 | - name: name 53 | in: path 54 | description: Name of Server to fetch 55 | required: true 56 | schema: 57 | type: string 58 | # format: underscore-lowercase 59 | responses: 60 | '201': 61 | description: server registered 62 | content: 63 | application/json: 64 | schema: 65 | $ref : '#/components/schemas/Server' 66 | default: 67 | description: unexpected error 68 | content: 69 | application/json: 70 | schema: 71 | $ref: '#/components/schemas/Error' 72 | put: 73 | summary: Update server configuration 74 | operationId: UpdateServer 75 | responses: 76 | '200': 77 | description: server updated 78 | content: 79 | application/json: 80 | schema: 81 | $ref : '#/components/schemas/Server' 82 | default: 83 | description: unexpected error 84 | content: 85 | application/json: 86 | schema: 87 | $ref: '#/components/schemas/Error' 88 | delete: 89 | summary: Remove the server with the given name 90 | operationId: DeleteServer 91 | responses: 92 | '200': 93 | description: serer deleted response 94 | /servers/{name}/password: 95 | patch: 96 | summary: Change the password associated with a server with the given name 97 | operationId: UpdateServerPassword 98 | requestBody: 99 | description: Password for the server configuration 100 | required: true 101 | content: 102 | text/plain: 103 | schema: 104 | required: 105 | - password 106 | properties: 107 | password: 108 | type: string 109 | responses: 110 | '200': 111 | description: password changed 112 | /databases: 113 | get: 114 | summary: Fetch all databases 115 | operationId: FetchAllDatabases 116 | responses: 117 | '200': 118 | description: All databases 119 | content: 120 | application/json: 121 | schema: 122 | $ref: '#/components/schemas/Databases' 123 | default: 124 | description: unexpected error 125 | content: 126 | application/json: 127 | schema: 128 | $ref: '#/components/schemas/Error' 129 | post: 130 | summary: Register Database 131 | operationId: RegisterDatabase 132 | responses: 133 | '201': 134 | description: Database registered 135 | content: 136 | application/json: 137 | schema: 138 | $ref: '#/components/schemas/Database' 139 | default: 140 | description: unexpected error 141 | content: 142 | application/json: 143 | schema: 144 | $ref: '#/components/schemas/Error' 145 | /databases/{name}: 146 | get: 147 | summary: Fetch database with specified name 148 | operationId: FetchDatabase 149 | responses: 150 | '200': 151 | description: Database fetched 152 | content: 153 | application/json: 154 | schema: 155 | $ref: '#/components/schemas/Database' 156 | default: 157 | description: unexpected error 158 | content: 159 | application/json: 160 | schema: 161 | $ref: '#/components/schemas/Error' 162 | put: 163 | summary: Update Database configuration 164 | operationId: UpdateDatabaseConfig 165 | responses: 166 | '200': 167 | description: Database updated 168 | content: 169 | application/json: 170 | schema: 171 | $ref: '#/components/schemas/Database' 172 | default: 173 | description: unexpected error 174 | content: 175 | application/json: 176 | schema: 177 | $ref: '#/components/schemas/Error' 178 | delete: 179 | summary: Remove database from configuraiton 180 | operationId: UnregisterDatabase 181 | responses: 182 | '200': 183 | description: Database unregistered 184 | content: 185 | application/json: 186 | schema: 187 | properties: 188 | message: 189 | type: string 190 | default: 191 | description: unexpected error 192 | content: 193 | application/json: 194 | schema: 195 | $ref: '#/components/schemas/Error' 196 | /databases/{name}/files: 197 | post: 198 | summary: Upload files to use for database restoration 199 | operationId: UploadFiles 200 | responses: 201 | '200': 202 | description: Files uploaded 203 | content: 204 | application/json: 205 | schema: 206 | properties: 207 | message: 208 | type: string 209 | default: 210 | description: unexpected error uploading files 211 | content: 212 | application/json: 213 | schema: 214 | $ref: '#/components/schemas/Error' 215 | /databases/{name}/restoration: 216 | post: 217 | summary: Perform restoration on database immediately 218 | operationId: RestoreDatabaseNow 219 | responses: 220 | '201': 221 | description: Operation started 222 | content: 223 | application/json: 224 | schema: 225 | properties: 226 | message: 227 | type: string 228 | default: 229 | description: unexpected error 230 | content: 231 | application/json: 232 | schema: 233 | $ref: '#/components/schemas/Error' 234 | /logs: 235 | get: 236 | summary: get logs 237 | operationId: get logs from program 238 | responses: 239 | '200': 240 | description: tail from the logs 241 | content: 242 | text/plain: 243 | schema: 244 | properties: 245 | logline: 246 | type: string 247 | default: 248 | description: unexpected error 249 | content: 250 | application/json: 251 | schema: 252 | $ref: '#/components/schemas/Error' 253 | components: 254 | schemas: 255 | Error: 256 | type: object 257 | properties: 258 | message: 259 | type: string 260 | code: 261 | type: integer 262 | Server: 263 | type: object 264 | required: 265 | - name 266 | - host 267 | - port 268 | - username 269 | properties: 270 | name: 271 | type: string 272 | host: 273 | type: string 274 | port: 275 | type: integer 276 | username: 277 | type: string 278 | password: 279 | type: string 280 | Servers: 281 | type: array 282 | items: 283 | $ref: '#/components/schemas/Server' 284 | Database: 285 | type: object 286 | required: 287 | - name 288 | - server 289 | - schedule 290 | - source 291 | properties: 292 | name: 293 | type: string 294 | server: 295 | type: string 296 | schedule: 297 | type: string 298 | source: 299 | $ref: '#/components/schemas/Source' 300 | Databases: 301 | type: array 302 | items: 303 | $ref: '#/components/schemas/Database' 304 | Source: 305 | type: object 306 | required: 307 | - type 308 | properties: 309 | type: 310 | type: string 311 | file: 312 | type: string 313 | schema: 314 | type: integer 315 | files: 316 | type: array 317 | items: 318 | type: string 319 | format: File path -------------------------------------------------------------------------------- /pg_reloaded-sample.yml: -------------------------------------------------------------------------------- 1 | # Absolute path to the directory containing postgresql client programs 2 | # The following client programs are searched for specifically: 3 | # psql, pg_restore, pg_dump 4 | psql_path: "/path/to/psql-dir" 5 | # Absolute path to the logfile, will be created if it does not exist 6 | log_file: "/path/to/logfile" 7 | servers: 8 | # name - A name to identify the server in the "databases" section 9 | # of the configuration 10 | - name: "my-development-server" 11 | # port - The host for the database 12 | host: "localhost" 13 | # port - The port for the database 14 | port: 5432 15 | # Username for the user must have CREATE DATABASE & DROP DATABASE privileges 16 | # Use the following to grant create privileges 17 | # `ALTER USER username CREATEDB;` 18 | username: "appuser" 19 | # Password for the user role on the database 20 | password: "password" 21 | databases: 22 | # name - The database name 23 | - name: "my_database_name" 24 | # server - The name of a server in the "servers" list 25 | server: "my-development-server" 26 | # schedule - Specifies the schedule for running the database restores in 27 | # daemon mode. Supports simple interval notation and CRON expressions 28 | schedule: "@every 24h" 29 | # Source specifies where to get the schema and data to restore 30 | source: 31 | # The type of file(s) to restore the database from. 32 | # The following types are (will be) supported: 33 | # 34 | # * sql - load schema & data from SQL files using psql 35 | # * tar - load schema & data from SQL files using pg_restore 36 | # * csv - load data from CSV files using pgfutter 37 | # * json - load data from JSON files using pgfutter 38 | type: "sql" 39 | 40 | # The absolute path to the file to restore the database from 41 | file: "/path/to/file" 42 | 43 | # The absolute path to the schema file to be used to create tables, functions etc.. 44 | # Schema MUST be specified if source type is one of: csv, json 45 | # or if the SQL file only contains data 46 | schema: "/path/to/schema/file.sql" 47 | 48 | # The files to load data from 49 | # MUST be specified if source type is one of: csv, json 50 | files: 51 | - "/path/to/file1.json" 52 | - "/path/to/file2.json" 53 | # - ... 54 | - "/path/to/file99.json" -------------------------------------------------------------------------------- /pg_reloaded/commands.go: -------------------------------------------------------------------------------- 1 | package pg_reloaded 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path" 9 | 10 | "github.com/hashicorp/go-hclog" 11 | ) 12 | 13 | func RunRestoreDatabase(psqlDir, username, database, host string, port int, file, password string) error { 14 | if "postgres" == database { 15 | return errors.New("Nope, I cannot CREATE the 'postgres' database.") 16 | } 17 | args := createDatabaseArgs(username, database, host, port) 18 | fmt.Println("Running", command(psqlDir, "psql"), args) 19 | cmd := exec.Command(command(psqlDir, "psql"), args...) 20 | // cmd.Dir = db.Source.GetDir() 21 | cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", password)) 22 | hclog.Default().Debug("Restoring database.", 23 | "username", username, 24 | "database", database) 25 | output, err := cmd.CombinedOutput() 26 | fmt.Println(string(output)) 27 | if err != nil || !cmd.ProcessState.Success() { 28 | hclog.Default().Error("Failed to run 'psql'.", 29 | "error", err, 30 | "output", string(output)) 31 | return err 32 | } 33 | return RunPsql(psqlDir, username, database, host, port, file, password) 34 | } 35 | 36 | // RunDropDatabase Executes a DROP DATABASE via psql 37 | func RunDropDatabase(psqlDir, username, database, host string, port int, password string) error { 38 | if "postgres" == database { 39 | return errors.New("Nope, I cannot DROP the 'postgres' database.") 40 | } 41 | args := dropDatabaseArgs(username, database, host, port) 42 | fmt.Println("Running", command(psqlDir, "psql"), args) 43 | cmd := exec.Command(command(psqlDir, "psql"), args...) 44 | 45 | cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", password)) 46 | hclog.Default().Debug("Dropping database.", 47 | "username", username, 48 | "database", database) 49 | output, err := cmd.CombinedOutput() 50 | fmt.Println(string(output)) 51 | if err != nil || !cmd.ProcessState.Success() { 52 | hclog.Default().Error("Failed to run 'psql'.", 53 | "error", err, 54 | "output", string(output)) 55 | return err 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // RunPgRestore Executes a database restore using pg_restore 62 | func RunPgRestore(psqlDir, username, database, host string, port int, file, password string) error { 63 | args := append(psqlArgs(username, database, host, port), file) 64 | fmt.Println("Running", command(psqlDir, "pg_restore"), args) 65 | cmd := exec.Command(command(psqlDir, "pg_restore"), args...) 66 | 67 | cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", password)) 68 | hclog.Default().Debug("Running restore via pg_restore.", 69 | "database", database, 70 | "file", file, 71 | "username", username) 72 | output, err := cmd.CombinedOutput() 73 | fmt.Println(string(output)) 74 | if err != nil || !cmd.ProcessState.Success() { 75 | hclog.Default().Error("Failed to run 'pg_restore'.", 76 | "error", err, "output", string(output)) 77 | return err 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // RunPsql Executes a command using psql 84 | func RunPsql(psqlDir, username, database, host string, port int, file, password string) error { 85 | args := append(psqlArgs(username, database, host, port), "-f", file) 86 | fmt.Println("Running", command(psqlDir, "psql"), args) 87 | cmd := exec.Command(command(psqlDir, "psql"), args...) 88 | // cmd.Dir = db.Source.GetDir() 89 | cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", password)) 90 | hclog.Default().Debug("Running restore via psql.", 91 | "database", database, 92 | "file", file, 93 | "username", username) 94 | output, err := cmd.CombinedOutput() 95 | fmt.Println(string(output)) 96 | if err != nil || !cmd.ProcessState.Success() { 97 | hclog.Default().Error("Failed to run 'psql'.", 98 | "error", err, "output", string(output)) 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | 105 | // createDatabaseArgs Creates an argument string for passing to psql to CREATE a database 106 | func createDatabaseArgs(username, database, host string, port int) []string { 107 | return append( 108 | psqlArgs(username, "postgres", host, port), 109 | "-c", fmt.Sprintf("CREATE DATABASE %s OWNER %s", database, username)) 110 | } 111 | 112 | // dropDatabaseArgs Creates an argument string for passing to psql to DROP a database 113 | func dropDatabaseArgs(username, database, host string, port int) []string { 114 | return append( 115 | psqlArgs(username, "postgres", host, port), 116 | "-c", fmt.Sprintf("DROP DATABASE %s", database)) 117 | } 118 | 119 | // psqlArgs Creates an argument string for passing to Postgresql clients 120 | func psqlArgs(username, database, host string, port int) []string { 121 | args := []string{ 122 | "-U", username, 123 | "-h", host, 124 | "-p", fmt.Sprintf("%d", port), 125 | "-d", database, 126 | } 127 | 128 | return args 129 | } 130 | 131 | // command Returns command with base directory if provided or just the command name 132 | func command(dir, commandName string) string { 133 | if dir == "" { 134 | return commandName 135 | } 136 | return path.Join(dir, commandName) 137 | } 138 | 139 | // DropAndRestoreUsingPsql Creates a command-line to drop a database and restore via Psql 140 | func DropAndRestoreUsingPsql(psqlDir, username, database, host string, port int, file, password string) string { 141 | return fmt.Sprintf("psql X && psql Y %s", "yellow") 142 | //return fmt.Sprintf("psql %s && psql %s < %s", 143 | // dropDatabaseArgs(username, database, host, port), 144 | // psqlArgs(username, database, host, port), 145 | // file, 146 | //) 147 | } 148 | -------------------------------------------------------------------------------- /pg_reloaded/commands_test.go: -------------------------------------------------------------------------------- 1 | package pg_reloaded 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCreateDatabaseArgs(t *testing.T) { 8 | want := []string{ 9 | "-U", "user", 10 | "-h", "my-host", 11 | "-p", "5432", 12 | "-d", "postgres", 13 | "-c", "CREATE DATABASE test_database OWNER user", 14 | } 15 | have := createDatabaseArgs("user", "test_database", "my-host", 5432) 16 | 17 | for idx, val := range want { 18 | if have[idx] != val { 19 | t.Errorf("TestCreateDatabaseArgs want: %s have:%s", val, have[idx]) 20 | } 21 | } 22 | } 23 | 24 | func TestDropDatabaseArgs(t *testing.T) { 25 | want := []string{ 26 | "-U", "user", 27 | "-h", "my-host", 28 | "-p", "5432", 29 | "-d", "postgres", 30 | "-c", "DROP DATABASE test_database", 31 | } 32 | have := dropDatabaseArgs("user", "test_database", "my-host", 5432) 33 | 34 | for idx, val := range want { 35 | if have[idx] != val { 36 | t.Errorf("TestDropDatabaseArgs want: %s have:%s", val, have[idx]) 37 | } 38 | } 39 | } 40 | 41 | func TestPsqlArgs(t *testing.T) { 42 | want := []string{ 43 | "-U", "user", 44 | "-h", "my-host", 45 | "-p", "5432", 46 | "-d", "test_database", 47 | } 48 | have := psqlArgs("user", "test_database", "my-host", 5432) 49 | 50 | for idx, val := range want { 51 | if have[idx] != val { 52 | t.Errorf("TestPsqlArgs want: %s have:%s", val, have[idx]) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pg_reloaded/config.go: -------------------------------------------------------------------------------- 1 | package pg_reloaded 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/nndi-oss/pg_reloaded/cron" 11 | ) 12 | 13 | // Config stores all the configuration information for pg_reloaded 14 | type Config struct { 15 | Daemonize bool 16 | PsqlDir string `mapstructure:"psql_path"` 17 | LogPath string `mapstructure:"log_path"` 18 | Servers []ServerConfig `mapstructure:"servers"` 19 | Databases []DatabaseConfig `mapstructure:"databases"` 20 | } 21 | 22 | type ServerConfig struct { 23 | Name string `mapstructure:"name"` 24 | Host string `mapstructure:"host"` 25 | Port int `mapstructure:"port"` 26 | Username string `mapstructure:"username"` 27 | Password string `mapstructure:"password"` 28 | } 29 | 30 | type DatabaseConfig struct { 31 | Name string `mapstructure:"name"` 32 | Server string `mapstructure:"server"` 33 | Schedule string `mapstructure:"schedule"` 34 | Source SourceConfig `mapstructure:"source"` 35 | } 36 | 37 | type SourceConfig struct { 38 | Type string `mapstructure:"type"` 39 | File string `mapstructure:"file"` 40 | Files []string `mapstructure:"files"` 41 | Schema string `mapstructure:"schema"` 42 | } 43 | 44 | // GetServerByName Gets a server by name from the list of servers 45 | // returns nil if not available 46 | func (c Config) GetServerByName(name string) *ServerConfig { 47 | for _, server := range c.Servers { 48 | if name == server.Name { 49 | return &server 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func Validate(cfg Config) error { 56 | if cfg.PsqlDir != "" { 57 | if _, err := os.Stat(cfg.PsqlDir); os.IsNotExist(err) { 58 | return errors.New(fmt.Sprintf("The path for Postgresql clients (psql_path) '%s' does not exist or cannot be read", cfg.PsqlDir)) 59 | } 60 | } 61 | 62 | if cfg.Servers == nil || len(cfg.Servers) < 1 { 63 | return errors.New("Please specify at least one server under 'servers'") 64 | } 65 | 66 | if cfg.Databases == nil || len(cfg.Databases) < 1 { 67 | return errors.New("Please specify at least one database under 'databases'") 68 | } 69 | for idx, d := range cfg.Databases { 70 | if d.Name == "" { 71 | return errors.New(fmt.Sprintf("Please specify the name for database at index: %d", idx)) 72 | } 73 | if d.Server == "" { 74 | return errors.New(fmt.Sprintf("Please specify the name for the server for database '%s'", d.Name)) 75 | } 76 | if s := cfg.GetServerByName(d.Server); s == nil { 77 | return errors.New(fmt.Sprintf("Server for database '%s' does not exist in 'servers' list", d.Name)) 78 | } 79 | if d.Schedule == "" { 80 | return errors.New(fmt.Sprintf("Please provide a 'schedule' for database '%s'", d.Name)) 81 | } 82 | if _, err := cron.Parse(d.Schedule); err != nil { 83 | return errors.New(fmt.Sprintf("Invalid expression for 'schedule' for database '%s'. Error: %v", d.Name, err)) 84 | } 85 | stype := strings.ToLower(d.Source.Type) 86 | 87 | matched, err := regexp.MatchString("^(sql|tar)$", stype) 88 | if err != nil { 89 | return err 90 | } 91 | // TODO: Add conditions when CSV and JSON are supported || stype != "csv" || stype != "json" 92 | if !matched { 93 | return errors.New(fmt.Sprintf("Provided source type '%s' is not supported for database '%s'.", d.Source.Type, d.Name)) 94 | } else { 95 | if _, err := os.Stat(d.Source.File); os.IsNotExist(err) { 96 | return errors.New(fmt.Sprintf("File '%s' does not exist. File must be provided for source type '%s' for database '%s'.", d.Source.File, d.Source.Type, d.Name)) 97 | } 98 | } 99 | 100 | // TODO: Check configuration for CSV and JSON source types i.e. d.Source.Schema, d.Source.Files 101 | // if stype == "csv" || stype == "json" { 102 | // if _, err := os.Stat(d.Source.Schema); os.IsNotExist(err) { 103 | // return errors.New(fmt.Sprintf("Schema File '%s' does not exist - a schema must be provided for source type '%s' for database '%s'.", d.Source.Schema, d.Source.Type, d.Name)) 104 | // } 105 | // for _, file := range d.Source.Files { 106 | // if _, err := os.Stat(d.Source.File); os.IsNotExist(err) { 107 | // return errors.New(fmt.Sprintf("File '%s' does not exist. File must be provided for source type '%s' for database '%s'.", d.Source.File, d.Source.Type, d.Name)) 108 | // } 109 | // } 110 | // } 111 | } 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /pg_reloaded/config_test.go: -------------------------------------------------------------------------------- 1 | package pg_reloaded 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestValidatePsqlPath test validation of psqlDir 8 | func TestValidatePsqlPath(t *testing.T) { 9 | config := Config{PsqlDir: "non-existent-dir"} 10 | 11 | want := "The path for Postgresql clients (psql_path) 'non-existent-dir' does not exist or cannot be read" 12 | err := Validate(config) 13 | if err == nil { 14 | t.Error("Expected an error from Validate()") 15 | return 16 | } 17 | have := err.Error() 18 | if want != have { 19 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 20 | } 21 | } 22 | 23 | func TestValidateServersNil(t *testing.T) { 24 | config := Config{Servers: nil} 25 | 26 | want := "Please specify at least one server under 'servers'" 27 | err := Validate(config) 28 | if err == nil { 29 | t.Error("Expected an error from Validate()") 30 | return 31 | } 32 | have := err.Error() 33 | if want != have { 34 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 35 | } 36 | } 37 | 38 | func TestValidateServersEmpty(t *testing.T) { 39 | config := Config{Servers: []ServerConfig{}} 40 | 41 | want := "Please specify at least one server under 'servers'" 42 | err := Validate(config) 43 | if err == nil { 44 | t.Error("Expected an error from Validate()") 45 | return 46 | } 47 | have := err.Error() 48 | if want != have { 49 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 50 | } 51 | } 52 | 53 | func TestValidateDatabasesNil(t *testing.T) { 54 | config := Config{ 55 | Servers: []ServerConfig{ 56 | ServerConfig{ 57 | Host: "localhost", 58 | Port: 5432, 59 | Username: "user", 60 | Password: "password", 61 | }, 62 | }, 63 | Databases: nil, 64 | } 65 | 66 | want := "Please specify at least one database under 'databases'" 67 | err := Validate(config) 68 | if err == nil { 69 | t.Error("Expected an error from Validate()") 70 | return 71 | } 72 | have := err.Error() 73 | if want != have { 74 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 75 | } 76 | } 77 | 78 | func TestValidateDatabasesEmpty(t *testing.T) { 79 | config := Config{ 80 | Servers: []ServerConfig{ 81 | ServerConfig{ 82 | Host: "localhost", 83 | Port: 5432, 84 | Username: "user", 85 | Password: "password", 86 | }, 87 | }, 88 | Databases: []DatabaseConfig{}, 89 | } 90 | 91 | want := "Please specify at least one database under 'databases'" 92 | err := Validate(config) 93 | if err == nil { 94 | t.Error("Expected an error from Validate()") 95 | return 96 | } 97 | have := err.Error() 98 | if want != have { 99 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 100 | } 101 | } 102 | 103 | func TestValidateDatabasesEmptyName(t *testing.T) { 104 | config := Config{ 105 | Servers: []ServerConfig{ 106 | ServerConfig{ 107 | Host: "localhost", 108 | Port: 5432, 109 | Username: "user", 110 | Password: "password", 111 | }, 112 | }, 113 | Databases: []DatabaseConfig{ 114 | DatabaseConfig{ 115 | Name: "", 116 | }, 117 | }, 118 | } 119 | 120 | want := "Please specify the name for database at index: 0" 121 | err := Validate(config) 122 | if err == nil { 123 | t.Error("Expected an error from Validate()") 124 | return 125 | } 126 | have := err.Error() 127 | if want != have { 128 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 129 | } 130 | } 131 | 132 | func TestValidateDatabasesEmptyServer(t *testing.T) { 133 | config := Config{ 134 | Servers: []ServerConfig{ 135 | ServerConfig{ 136 | Name: "local", 137 | Host: "localhost", 138 | Port: 5432, 139 | Username: "user", 140 | Password: "password", 141 | }, 142 | }, 143 | Databases: []DatabaseConfig{ 144 | DatabaseConfig{ 145 | Name: "dev", 146 | Server: "", 147 | }, 148 | }, 149 | } 150 | 151 | want := "Please specify the name for the server for database 'dev'" 152 | err := Validate(config) 153 | if err == nil { 154 | t.Error("Expected an error from Validate()") 155 | return 156 | } 157 | have := err.Error() 158 | if want != have { 159 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 160 | } 161 | } 162 | 163 | func TestValidateDatabasesInvalidServer(t *testing.T) { 164 | config := Config{ 165 | Servers: []ServerConfig{ 166 | ServerConfig{ 167 | Name: "local", 168 | Host: "localhost", 169 | Port: 5432, 170 | Username: "user", 171 | Password: "password", 172 | }, 173 | }, 174 | Databases: []DatabaseConfig{ 175 | DatabaseConfig{ 176 | Name: "dev", 177 | Server: "localhost", 178 | }, 179 | }, 180 | } 181 | 182 | want := "Server for database 'dev' does not exist in 'servers' list" 183 | err := Validate(config) 184 | if err == nil { 185 | t.Error("Expected an error from Validate()") 186 | return 187 | } 188 | have := err.Error() 189 | if want != have { 190 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 191 | } 192 | } 193 | 194 | func TestValidateDatabasesEmptySchedule(t *testing.T) { 195 | config := Config{ 196 | Servers: []ServerConfig{ 197 | ServerConfig{ 198 | Name: "local", 199 | Host: "localhost", 200 | Port: 5432, 201 | Username: "user", 202 | Password: "password", 203 | }, 204 | }, 205 | Databases: []DatabaseConfig{ 206 | DatabaseConfig{ 207 | Name: "dev", 208 | Server: "local", 209 | Schedule: "", 210 | }, 211 | }, 212 | } 213 | 214 | want := "Please provide a 'schedule' for database 'dev'" 215 | err := Validate(config) 216 | if err == nil { 217 | t.Error("Expected an error from Validate()") 218 | return 219 | } 220 | have := err.Error() 221 | if want != have { 222 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 223 | } 224 | } 225 | 226 | func TestValidateDatabasesInvalidSourceType(t *testing.T) { 227 | config := Config{ 228 | Servers: []ServerConfig{ 229 | ServerConfig{ 230 | Name: "local", 231 | Host: "localhost", 232 | Port: 5432, 233 | Username: "user", 234 | Password: "password", 235 | }, 236 | }, 237 | Databases: []DatabaseConfig{ 238 | DatabaseConfig{ 239 | Name: "dev", 240 | Server: "local", 241 | Schedule: "@every 4h", 242 | Source: SourceConfig{ 243 | Type: "invalid", 244 | }, 245 | }, 246 | }, 247 | } 248 | 249 | want := "Provided source type 'invalid' is not supported for database 'dev'." 250 | err := Validate(config) 251 | if err == nil { 252 | t.Error("Expected an error from Validate()") 253 | return 254 | } 255 | have := err.Error() 256 | if want != have { 257 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 258 | } 259 | } 260 | 261 | func TestValidateDatabasesInvalidSourceFile(t *testing.T) { 262 | config := Config{ 263 | Servers: []ServerConfig{ 264 | ServerConfig{ 265 | Name: "local", 266 | Host: "localhost", 267 | Port: 5432, 268 | Username: "user", 269 | Password: "password", 270 | }, 271 | }, 272 | Databases: []DatabaseConfig{ 273 | DatabaseConfig{ 274 | Name: "dev", 275 | Server: "local", 276 | Schedule: "@every 24h", 277 | Source: SourceConfig{ 278 | Type: "sql", 279 | File: "non-existent-file", 280 | }, 281 | }, 282 | }, 283 | } 284 | 285 | want := "File 'non-existent-file' does not exist. File must be provided for source type 'sql' for database 'dev'." 286 | err := Validate(config) 287 | if err == nil { 288 | t.Error("Expected an error from Validate()") 289 | return 290 | } 291 | have := err.Error() 292 | if want != have { 293 | t.Errorf("Error\n\twant:%s\n\thave:%s", want, have) 294 | } 295 | } 296 | --------------------------------------------------------------------------------