├── .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 |
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 |
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 |
--------------------------------------------------------------------------------