Skip to content

Commit

Permalink
Support multiple Slack syncs through YAML configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
timoreimann committed Apr 6, 2020
1 parent a3e53ab commit fa8ef28
Show file tree
Hide file tree
Showing 14 changed files with 372 additions and 146 deletions.
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,51 @@ You will need to create a Slack app with the following permissions

(`groups.*` is only needed for private channels)

and invite it to the target channel. Afterwards, you can run something like the following:
and invite it to the target channel.

Next up, one or more _slack syncs_ must be configured, preferrably through a YAML configuration file. Here is an [example file](config.example.yaml):

```yaml
slackSyncs:
# name must be unique across all given syncs
- name: team-awesome
schedules:
# a schedule can be given by name...
- name: Awesome-Primary
# ...or by ID
- id: D34DB33F
channel:
name: awesome
# a channel can also be provided by ID:
# id: 1A3F8FGJ

# The on-call Slack user variables are named after the schedule names.
# Note how it is called ".AwesomePrimary" and not ".Awesome-Primary" because Go
# template variables support alphanumeric characters only, so pdsync
# strips off all illegal characters.
template: |-
primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>
# Set to true to skip updating the Slack channel topic
dryRun: false
```
Now pdsync can be started like the following:
```shell
pdsync --schedule-names Team-Primary --schedule-names Team-Secondary --channel-name team-awesome --template 'primary on-call: <@{{.TeamPrimary}}> secondary on-call: <@{{.TeamSecondary}}>'
pdsync --config config.example.yaml
```

(Add `--dry-run` to make this a no-op.)
This will update the topic of the `awesome` Slack channel mentioning the primary and secondary on-call Slack handles. The template variables match the PagerDuty schedule names.

This will update the topic of the `team-awesome` Slack channel mentioning the primary and secondary on-call Slack handles. The template variables match the PagerDuty schedule names.
**Note:** Go template variables take alphanumeric names only. _pdsync_ exposes channel names without unsupported characters in the template variables, which is why you will need to use `{{.AwesomePrimary}}` (as opposed to `{{.Awesome-Primary}}`) in the example above.

**Note:** Go template variables take alphanumeric names only. _pdsync_ exposes channel names without unsupported characters in the template variables, which is why you will need to use `{{.TeamPrimary}}` (as opposed to `{{.Team-Primary}}`) above.
It is also possible to specify a single slack sync through CLI parameters:

```shell
pdsync --schedule-names Awesome-Primary --schedule-ids D34DB33F --channel-name awesome --template 'primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>'
```

(Add `--dry-run` to make this a no-op.)

Run the tool with `--help` for details.

Expand Down
21 changes: 21 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
slackSyncs:
# name must be unique across all given syncs
- name: team-awesome
schedules:
# a schedule can be given by name...
- name: Awesome-Primary
# ...or by ID
- id: D34DB33F
channel:
name: awesome
# a channel can also be provided by ID:
# id: 1A3F8FGJ

# The on-call Slack user variables are named after the schedule names.
# Note how it is called ".AwesomePrimary" and not ".Awesome-Primary" because Go
# template variables support alphanumeric characters only, so pdsync
# strips off all illegal characters.
template: |-
primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>
# Set to true to skip updating the Slack channel topic
dryRun: false
129 changes: 129 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package main

import (
"fmt"
"io/ioutil"

"gopkg.in/yaml.v2"
)

// ConfigSchedule represents a PagerDuty schedule identified by either ID or name.
type ConfigSchedule struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
}

// ConfigChannel represents a Slack channel identified by either ID or name.
type ConfigChannel struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
}

// ConfigSlackSync represents a synchronization between a set of PagerDuty schedules and a Slack channel.
type ConfigSlackSync struct {
Name string `yaml:"name"`
Schedules []ConfigSchedule `yaml:"schedules"`
Channel ConfigChannel `yaml:"channel"`
Template string `yaml:"template"`
DryRun bool `yaml:"dryRun"`
}

type config struct {
SlackSyncs []ConfigSlackSync `yaml:"slackSyncs"`
}

func generateConfig(p params) (config, error) {
var (
cfg config
err error
)

if p.config != "" {
cfg, err = readConfigFile(p.config)
if err != nil {
return config{}, err
}
} else {
if p.tmplFile != "" {
b, err := ioutil.ReadFile(p.tmplFile)
if err != nil {
return config{}, err
}
p.tmplString = string(b)
}
cfg = singleSlackSync(p)
}

// If specified, let the global dry-run parameter override per-sync dry-run
// values.
if p.dryRun != nil {
for i := range cfg.SlackSyncs {
cfg.SlackSyncs[i].DryRun = *p.dryRun
}
}

err = validateConfig(&cfg)
if err != nil {
return config{}, err
}

return cfg, err
}

func readConfigFile(file string) (config, error) {
content, err := ioutil.ReadFile(file)
if err != nil {
return config{}, err
}

var cfg config
err = yaml.Unmarshal(content, &cfg)
return cfg, err
}

func singleSlackSync(p params) config {
slackSync := ConfigSlackSync{
Name: "default",
Channel: ConfigChannel{
ID: p.channelID,
Name: p.channelName,
},
Template: p.tmplString,
}
for _, scheduleID := range p.scheduleIDs {
slackSync.Schedules = append(slackSync.Schedules, ConfigSchedule{ID: scheduleID})
}
for _, scheduleName := range p.scheduleNames {
slackSync.Schedules = append(slackSync.Schedules, ConfigSchedule{Name: scheduleName})
}

return config{
SlackSyncs: []ConfigSlackSync{slackSync},
}
}

func validateConfig(cfg *config) error {
foundNames := map[string]bool{}
for _, sync := range cfg.SlackSyncs {
if _, ok := foundNames[sync.Name]; ok {
return fmt.Errorf("slack sync name %q already used", sync.Name)
}
foundNames[sync.Name] = true

for _, cfgSchedule := range sync.Schedules {
if cfgSchedule.ID == "" && cfgSchedule.Name == "" {
return fmt.Errorf("slack sync %q invalid: must specify either schedule ID or schedule name", sync.Name)
}
}

if sync.Channel.ID == "" && sync.Channel.Name == "" {
return fmt.Errorf("slack sync %q invalid: must specify either channel ID or channel name", sync.Name)
}

if sync.Template == "" {
return fmt.Errorf("slack sync %q invalid: template is missing", sync.Name)
}
}

return nil
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ require (
github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2
github.com/slack-go/slack v0.6.3
github.com/urfave/cli/v2 v2.1.1
gopkg.in/yaml.v2 v2.2.8
)
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@ github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
4 changes: 2 additions & 2 deletions kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Do the following to deploy _pdsync_ into a Kubernetes cluster:

1. Customize the template in the ConfigMap to your needs.
1. Create a secret containing your PagerDuty and Slack tokens: `kubectl create secret generic pdsync --from-literal=pagerduty-token=<PagerDuty token> --from-literal=slack-token=<Slack token>`
1. Tune the container's arguments in the Deployment.
1. Customize the configuration in the ConfigMap to your needs.
1. Deploy everything into your cluster: `kubectl apply -f .`
1. Remove the `--dry-run` flag once you're ready for the real deal.
13 changes: 11 additions & 2 deletions kubernetes/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,14 @@ kind: ConfigMap
metadata:
name: pdsync-config
data:
template.txt: |
primary on-call: <@{{.TeamPrimary}}> secondary on-call: <@{{.TeamSecondary}}>
config.yaml: |
slackSyncs:
- name: team-awesome
schedules:
- name: Awesome-Primary
- id: D34DB33F
channel:
name: awesome
template: |-
primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>
dryRun: false
10 changes: 2 additions & 8 deletions kubernetes/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,8 @@ spec:
key: slack-token
optional: false
args:
- --schedule-names
- <primary schedule name>
- --schedule-names
- <secondary schedule name>
- --channel-name
- <channel name>
- --template-file
- /config/template.txt
- --config
- /config/config.yaml
- --daemon
- --dry-run
volumeMounts:
Expand Down
66 changes: 23 additions & 43 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ package main
import (
"context"
"fmt"
"io/ioutil"
"os"
"os/signal"
"regexp"
"sync"
"syscall"
"text/template"
"time"

"github.com/urfave/cli/v2"
Expand All @@ -23,7 +21,10 @@ var (
)

func main() {
var p params
var (
p params
dryRun bool
)
app := &cli.App{
Name: "pdsync",
Usage: "sync PagerDuty on-call schedules to Slack",
Expand Down Expand Up @@ -54,6 +55,11 @@ By default, the program will terminate after a single run. Use the --daemon flag
EnvVars: []string{"SLACK_TOKEN"},
Required: true,
},
&cli.StringFlag{
Name: "config",
Usage: "config file to use",
Destination: &p.config,
},
&cli.StringSliceFlag{
Name: "schedule-names",
Usage: "names of PageDuty schedules to sync periodically",
Expand Down Expand Up @@ -96,12 +102,15 @@ By default, the program will terminate after a single run. Use the --daemon flag
&cli.BoolFlag{
Name: "dry-run",
Usage: "do not update topic",
Destination: &p.dryRun,
Destination: &dryRun,
},
},
Action: func(c *cli.Context) error {
p.scheduleNames = c.StringSlice("schedule-names")
p.scheduleIDs = c.StringSlice("schedule-ids")
if c.IsSet("dry-run") {
p.dryRun = &dryRun
}
return realMain(p)
},
}
Expand All @@ -115,24 +124,9 @@ By default, the program will terminate after a single run. Use the --daemon flag
}

func realMain(p params) error {
if len(p.scheduleNames) == 0 && len(p.scheduleIDs) == 0 {
return fmt.Errorf("one of --schedule-names or --schedule-ids must be provided")
}

if p.channelName == "" && p.channelID == "" {
return fmt.Errorf("one of --channel-name or --channel-id must be provided")
}

if p.tmplString == "" && p.tmplFile == "" {
return fmt.Errorf("one of --template or --template-file must be provided")
}

if p.tmplFile != "" {
b, err := ioutil.ReadFile(p.tmplFile)
if err != nil {
return err
}
p.tmplString = string(b)
cfg, err := generateConfig(p)
if err != nil {
return err
}

if p.daemonUpdateFrequency < minDaemonUpdateFrequency {
Expand All @@ -142,40 +136,26 @@ func realMain(p params) error {
sp := syncerParams{
pdClient: newPagerDutyClient(pdToken),
slClient: newSlackClient(slToken),
dryRun: p.dryRun,
}
var err error
sp.tmpl, err = template.New("topic").Parse(p.tmplString)
if err != nil {
return fmt.Errorf("failed to parse template %q: %s", p.tmplString, err)
}

syncer := newSyncer(sp)

ctx := context.Background()

slChannel, err := sp.slClient.getChannel(ctx, p.channelName, p.channelID)
fmt.Println("Getting Slack users")
sp.slackUsers, err = sp.slClient.getSlackUsers(ctx)
if err != nil {
return fmt.Errorf("failed to get Slack channel: %s", err)
return fmt.Errorf("failed to get Slack users: %s", err)
}
fmt.Printf("Found channel %q (ID %s)\n", slChannel.Name, slChannel.ID)
fmt.Printf("Found %d Slack user(s)\n", len(sp.slackUsers))

fmt.Println("Getting Slack users")
slUsers, err := sp.slClient.getSlackUsers(ctx)
slSyncs, err := sp.createSlackSyncs(context.TODO(), cfg)
if err != nil {
return err
}
fmt.Printf("Found %d user(s)\n", len(slUsers))

fmt.Println("Getting PagerDuty schedules")
pdSchedules, err := sp.pdClient.getSchedules(p.scheduleIDs, p.scheduleNames)
if err != nil {
return fmt.Errorf("failed to get PagerDuty schedules: %s", err)
}
fmt.Printf("Found %d PagerDuty schedule(s)\n", len(pdSchedules))
syncer := newSyncer(sp)

runFunc := func() error {
return syncer.Run(ctx, pdSchedules, slChannel, slUsers)
return syncer.Run(ctx, slSyncs)
}
if !p.daemon {
return runFunc()
Expand Down
Loading

0 comments on commit fa8ef28

Please sign in to comment.