diff --git a/.env.example b/.env.example index 38ac7a5..de30c76 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,7 @@ -APP_ENV=development PORT=XXXX USERNAME=user PASSWORD=pwd -SERVICES=@,@ +SERVICES=:@,@ TRELLO_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TRELLO_API_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TRELLO_BOARD_ID=xxxxxxxxxxxxxxxxxxxxxxxx diff --git a/README.md b/README.md index abec70d..4f9273d 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ Automation feature is supported only by the [server](#server-mode-configuration) --- ## Runner Mode Configuration -Create a [service configuration](#service-configuration) file based on `config.example.json`. By default, the runner looks for a file called `config.json` in the current working directory. - -You can trigger a synchronization by simply executing the runner: +Create a [service configuration](#service-configuration) file based on `config.example.json`. You can trigger a synchronization by simply executing the runner: ```sh # run this as a scheduled (cron) job -go run ./cmd/runner +go run ./cmd/runner -c /path/to/config/file ``` -Alternatively, you can specify a custom config file path using the `-c` flag: +If the `-c` flag is omitted, the runner looks for a file called `config.json` in the current working directory: ```sh -go run ./cmd/runner -c /path/to/config/file +# these two are equivalent: +go run ./cmd/runner +go run ./cmd/runner -c ./config.json ``` --- @@ -65,28 +65,38 @@ curl \ -H "Authorization: Basic :)>" ``` +### Automation To enable automation for one or more services: 1. Create a [Trello webhook](#trello-webhooks-reference), where the callback URL is `/trello-webhook`. -2. Set the `SERVICES` environment variable, configuring a 1-on-1 mapping of Trello labels to service endpoints. +2. Set the `SERVICES` environment variable, a comma-separated list of service configuration strings: + * A service configuration string must contain the Trello label ID and the service endpoint: + ```sh + # trello label ID: 1234 + # service enpoint URL: http://localhost:3333/entrello + 1234@http://localhost:3333/entrello + ``` + * It may additionally contain an API secret – _alphanumeric only_ – for authentication purposes: + ```sh + # the HTTP header "X-Api-Key" will be set to "SuPerSecRetPassW0rd" in each request + 1234:SuPerSecRetPassW0rd@http://localhost:3333/entrello + ``` --- ## Service Configuration Each service must return a JSON array of [Trello card objects][1] upon a `GET` request. -For each service, you must set the following configuration parameters: +#### Mandatory configuration parameters - `name` — Service name. -- `endpoint` — Service endpoint. +- `endpoint` — Service endpoint URL. -- `strict` — Whether stale cards should be deleted from the board upon synchronization (boolean). +- `label_id` — Trello label ID. A label ID can be associated with no more than one service. -- `label_id` — Trello label ID. A label ID must not be associated for more than one service. +- `list_id` — Trello list ID, i.e. where to insert new cards. The list must be in the board specified by the root-level `board_id` config parameter. -- `list_id` — Trello list ID, specifying where to insert new cards. The list must be in the board specified by the root-level `board_id` config parameter. - -- `period` — Polling period for the service. Determines how often a service should be polled. A few examples: +- `period` — Polling period. A few examples: ```json // poll on 3rd, 6th, 9th, ... of each month, at 00:00 "period": { @@ -108,11 +118,16 @@ For each service, you must set the following configuration parameters: // poll on each execution "period": { - "type": "default", - "interval": 0 + "type": "default" } ``` +#### Optional configuration parameters + +- `secret` — Alphanumeric API secret. If present, `entrello` will put it in the `X-Api-Key` HTTP header. + +- `strict` — Whether stale cards should be deleted from the board upon synchronization. `false` by default. + --- ## Running With Docker diff --git a/cmd/server/main.go b/cmd/server/main.go index 6f3dcff..8e7e728 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,7 +23,10 @@ func main() { http.HandleFunc("/", handlePollRequest) http.HandleFunc("/trello-webhook", handleTrelloWebhookRequest) - http.ListenAndServe(fmt.Sprintf(":%s", config.ServerCfg.Port), nil) + + if err := http.ListenAndServe(fmt.Sprintf(":%s", config.ServerCfg.Port), nil); err != nil { + logger.Error("Could not start server: %v", err) + } } func handlePollRequest(w http.ResponseWriter, req *http.Request) { diff --git a/config.example.json b/config.example.json index 75fe572..9791192 100644 --- a/config.example.json +++ b/config.example.json @@ -9,6 +9,7 @@ { "name": "Github Issues", "endpoint": "http://", + "secret": "youwish", "strict": true, "label_id": "xxxxxxxxxxxxxxxxxxxxxxxx", "list_id": "xxxxxxxxxxxxxxxxxxxxxxxx", diff --git a/internal/config/config.go b/internal/config/config.go index 18602a4..6160fff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ type Period struct { type Service struct { Name string `json:"name"` Endpoint string `json:"endpoint"` + Secret string `json:"secret"` Strict bool `json:"strict"` Label string `json:"label_id"` List string `json:"list_id"` diff --git a/internal/config/init.go b/internal/config/init.go index 8b6ef2f..3fa898d 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -3,33 +3,17 @@ package config import ( "fmt" "os" - "strings" "github.com/joho/godotenv" ) func init() { - appEnv := os.Getenv("APP_ENV") - if appEnv != "production" { - godotenv.Load() - } - - serializedServices := strings.Split(os.Getenv("SERVICES"), ",") - if appEnv != "production" && os.Getenv("SERVICES") == "" { - serializedServices = []string{} - } - - services := make([]Service, 0, len(serializedServices)) + godotenv.Load() - for _, service := range serializedServices { - parts := strings.Split(service, "@") - if len(parts) != 2 { - panic(fmt.Sprintf("invalid service configuration string: %s", service)) - } - services = append(services, Service{ - Label: parts[0], - Endpoint: parts[1], - }) + services, err := parseServices(os.Getenv("SERVICES")) + if err != nil { + fmt.Println("Could not parse the environment variable 'SERVICES':", err) + os.Exit(1) } ServerCfg = ServerConfig{ diff --git a/internal/config/parsers.go b/internal/config/parsers.go new file mode 100644 index 0000000..5ec9323 --- /dev/null +++ b/internal/config/parsers.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "regexp" + "strings" +) + +var alphaNumeric = regexp.MustCompile(`^[a-zA-Z0-9]*$`) + +func parseServices(input string) ([]Service, error) { + if input == "" { + return []Service{}, nil + } + + serializedServices := strings.Split(input, ",") + services := make([]Service, 0, len(serializedServices)) + + for _, service := range serializedServices { + majorParts := strings.Split(service, "@") + if len(majorParts) != 2 { + return nil, fmt.Errorf( + "expected only one occurrence of '@', got %d in %s", + len(majorParts)-1, + service, + ) + } + + minorParts := strings.Split(majorParts[0], ":") + if len(minorParts) > 2 { + return nil, fmt.Errorf( + "expected at most one occurrence of ':', got %d in %s", + len(minorParts)-1, + service, + ) + } + + if !alphaNumeric.MatchString(minorParts[0]) { + return nil, fmt.Errorf("unexpected non-alphanumeric characters in %s", service) + } + + secret := "" + if len(minorParts) > 1 { + if !alphaNumeric.MatchString(minorParts[1]) { + return nil, fmt.Errorf("unexpected non-alphanumeric characters in %s", service) + } + secret = minorParts[1] + } + + if !strings.HasPrefix(majorParts[1], "http") { + return nil, fmt.Errorf("service endpoint URL does not start with 'http' in %s", service) + } + + services = append(services, Service{ + Label: minorParts[0], + Secret: secret, + Endpoint: majorParts[1], + }) + } + + return services, nil +} diff --git a/internal/config/parsers_test.go b/internal/config/parsers_test.go new file mode 100644 index 0000000..e88d628 --- /dev/null +++ b/internal/config/parsers_test.go @@ -0,0 +1,78 @@ +package config + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParseServices(t *testing.T) { + tt := []struct { + name string + input string + isValid bool + services []Service + }{ + { + name: "simple service without secret", + input: "label@http://example.com", + isValid: true, + services: []Service{{Label: "label", Secret: "", Endpoint: "http://example.com"}}, + }, + { + name: "simple service with secret", + input: "label:secret@http://example.com", + isValid: true, + services: []Service{{Label: "label", Secret: "secret", Endpoint: "http://example.com"}}, + }, + { + name: "service with secret containing numbers and uppercase letters", + input: "label:aBcD1230XyZ@http://example.com", + isValid: true, + services: []Service{{Label: "label", Secret: "aBcD1230XyZ", Endpoint: "http://example.com"}}, + }, + { + name: "endpoint URL does not start with 'http'", + input: "label@example.com", + isValid: false, + }, + { + name: "no '@' delimiter", + input: "label-http://example.com", + isValid: false, + }, + { + name: "multiple '@' delimiters", + input: "label@joe@example.com", + isValid: false, + }, + { + name: "multiple ':' delimiters", + input: "label:super:secret:password@http://example.com", + isValid: false, + }, + { + name: "non-alphanumeric characters in label", + input: "definitely$$not_*?a+Trello.Label@http://example.com", + isValid: false, + }, + { + name: "non-alphanumeric characters in secret", + input: "label:-?_*@http://example.com", + isValid: false, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + services, err := parseServices(tc.input) + if tc.isValid != (err == nil) { + t.Errorf("expected valid output? %v. Got error: %s", tc.isValid, err) + return + } + if diff := cmp.Diff(services, tc.services); diff != "" { + t.Errorf("services diff: %s", diff) + } + }) + } +} diff --git a/internal/services/poller.go b/internal/services/poller.go index 54037a3..e2e7b58 100644 --- a/internal/services/poller.go +++ b/internal/services/poller.go @@ -13,59 +13,80 @@ import ( "github.com/utkuufuk/entrello/pkg/trello" ) -// getServices returns a slice of services & all service labels as a separate slice -func getServices(srcArr []config.Service, now time.Time) (services []config.Service, labels []string, err error) { - for _, src := range srcArr { - if ok, err := shouldPoll(src, now); !ok { +// getServicesToPoll returns a slice of services to poll & another slice of relevant service labels +func getServicesToPoll( + serviceArr []config.Service, + now time.Time, +) ( + services []config.Service, + labels []string, + err error, +) { + for _, service := range serviceArr { + if ok, err := shouldPoll(service, now); !ok { if err != nil { - return services, labels, fmt.Errorf("could not check if '%s' should be queried or not: %v", src.Name, err) + return services, labels, fmt.Errorf( + "could not check if '%s' should be queried or not: %w", + service.Name, + err, + ) } continue } - services = append(services, src) - labels = append(labels, src.Label) + services = append(services, service) + labels = append(labels, service.Label) } return services, labels, nil } // shouldPoll checks if a the service should be polled at the given time instant -func shouldPoll(src config.Service, date time.Time) (bool, error) { - interval := src.Period.Interval +func shouldPoll(service config.Service, date time.Time) (bool, error) { + interval := service.Period.Interval if interval < 0 { return false, fmt.Errorf("period interval must be a positive integer, got: '%d'", interval) } - switch src.Period.Type { + switch service.Period.Type { case config.PeriodTypeDefault: return true, nil + case config.PeriodTypeDay: if interval > 31 { - return false, fmt.Errorf("daily interval cannot be more than 14, got: '%d'", interval) + return false, fmt.Errorf("day period cannot be more than 31, got %d", interval) } return date.Day()%interval == 0 && date.Hour() == 0 && date.Minute() == 0, nil + case config.PeriodTypeHour: if interval > 23 { - return false, fmt.Errorf("hourly interval cannot be more than 23, got: '%d'", interval) + return false, fmt.Errorf("hour period cannot be more than 23, got %d", interval) } return date.Hour()%interval == 0 && date.Minute() == 0, nil + case config.PeriodTypeMinute: if interval > 60 { - return false, fmt.Errorf("minute interval cannot be more than 60, got: '%d'", interval) + return false, fmt.Errorf("minute period cannot be more than 60, got %d", interval) } return date.Minute()%interval == 0, nil } - return false, fmt.Errorf("unrecognized service period type: '%s'", src.Period.Type) + return false, fmt.Errorf("unrecognized service period type: '%s'", service.Period.Type) } // poll polls the given service and creates Trello cards for each item unless // a corresponding card already exists, also deletes the stale cards if strict mode is enabled -func poll(src config.Service, client trello.Client, wg *sync.WaitGroup) { +func poll(service config.Service, client trello.Client, wg *sync.WaitGroup) { defer wg.Done() - resp, err := http.Get(src.Endpoint) + req, err := http.NewRequest("GET", service.Endpoint, nil) + if err != nil { + logger.Error("could not create GET request to service '%s' endpoint: %v", service.Name, err) + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-Api-Key", service.Secret) + + resp, err := http.DefaultClient.Do(req) if err != nil { - logger.Error("could not make GET request to service '%s' endpoint: %v", src.Name, err) + logger.Error("could not make GET request to service '%s' endpoint: %v", service.Name, err) return } defer resp.Body.Close() @@ -76,26 +97,26 @@ func poll(src config.Service, client trello.Client, wg *sync.WaitGroup) { if err != nil { msg = err.Error() } - logger.Error("could not retrieve cards from service '%s': %v", src.Name, msg) + logger.Error("could not retrieve cards from service '%s': %v", service.Name, msg) return } var cards []trello.Card if err = json.NewDecoder(resp.Body).Decode(&cards); err != nil { - logger.Error("could not decode cards received from service '%s': %v", src.Name, err) + logger.Error("could not decode cards received from service '%s': %v", service.Name, err) return } - new, stale := client.FilterNewAndStale(cards, src.Label) + new, stale := client.FilterNewAndStale(cards, service.Label) for _, c := range new { - if err := client.CreateCard(c, src.Label, src.List); err != nil { + if err := client.CreateCard(c, service.Label, service.List); err != nil { logger.Error("could not create Trello card: %v", err) continue } logger.Info("created new card: %s", c.Name) } - if !src.Strict { + if !service.Strict { return } diff --git a/internal/services/poller_test.go b/internal/services/poller_test.go index 4fd57b0..7383609 100644 --- a/internal/services/poller_test.go +++ b/internal/services/poller_test.go @@ -79,7 +79,7 @@ func TestShouldPoll(t *testing.T) { pInterval: 40, date: time.Date(1990, time.Month(2), 4, 0, 0, 0, 0, time.UTC), ok: false, - err: fmt.Errorf("daily interval cannot be more than 14, got: '40'"), + err: fmt.Errorf("day period cannot be more than 31, got 40"), }, { name: "every 5 hours, at 15:00, should poll", @@ -111,7 +111,7 @@ func TestShouldPoll(t *testing.T) { pInterval: 25, date: time.Date(1990, time.Month(2), 4, 1, 0, 0, 0, time.UTC), ok: false, - err: fmt.Errorf("hourly interval cannot be more than 23, got: '25'"), + err: fmt.Errorf("hour period cannot be more than 23, got 25"), }, { name: "every 7 minutes, at 14:56, should poll", @@ -135,7 +135,7 @@ func TestShouldPoll(t *testing.T) { pInterval: 61, date: time.Date(1990, time.Month(2), 4, 1, 0, 0, 0, time.UTC), ok: false, - err: fmt.Errorf("minute interval cannot be more than 60, got: '61'"), + err: fmt.Errorf("minute period cannot be more than 60, got 61"), }, } diff --git a/internal/services/services.go b/internal/services/services.go index 4004125..347c175 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -20,9 +20,9 @@ func Poll(cfg config.RunnerConfig) error { return fmt.Errorf("invalid timezone location: %v", loc) } - services, labels, err := getServices(cfg.Services, time.Now().In(loc)) + services, labels, err := getServicesToPoll(cfg.Services, time.Now().In(loc)) if err != nil { - return fmt.Errorf("failed to get services to poll: %v", err) + return fmt.Errorf("failed to get services to poll: %w", err) } if len(services) == 0 { return nil @@ -31,7 +31,7 @@ func Poll(cfg config.RunnerConfig) error { client := trello.NewClient(cfg.Trello) if err := client.LoadBoard(labels); err != nil { - return fmt.Errorf("Could not load existing cards from the board: %v", err) + return fmt.Errorf("Could not load existing cards from the board: %w", err) } var wg sync.WaitGroup @@ -56,12 +56,19 @@ func Notify(card trello.Card, services []config.Service) error { if slices.Contains(labelIds, service.Label) { postBody, err := json.Marshal(card) if err != nil { - return fmt.Errorf("could not marshal archived card: %v", err) + return fmt.Errorf("could not marshal archived card: %w", err) } - resp, err := http.Post(service.Endpoint, "application/json", bytes.NewBuffer(postBody)) + req, err := http.NewRequest("POST", service.Endpoint, bytes.NewBuffer(postBody)) if err != nil { - return fmt.Errorf("could not post archived card data to %s: %v", service.Endpoint, err) + return fmt.Errorf("could not create POST request to %s: %w", service.Endpoint, err) + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-Api-Key", service.Secret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("could not post archived card data to %s: %w", service.Endpoint, err) } defer resp.Body.Close() }