diff --git a/.github/workflows/sync-oncall.yml b/.github/workflows/sync-oncall.yml index 8f6bd45..8dade9e 100644 --- a/.github/workflows/sync-oncall.yml +++ b/.github/workflows/sync-oncall.yml @@ -29,6 +29,9 @@ jobs: - name: Sync oncall rotation env: GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} + SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} run: | cd synconcall - ./synconcall --config=../dev.oncall --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com + ./synconcall --config=../dev.oncall \ + --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com \ + --slack-group=S0AAPDZBNQL diff --git a/synconcall/SLACK_SETUP.md b/synconcall/SLACK_SETUP.md new file mode 100644 index 0000000..3bc0f19 --- /dev/null +++ b/synconcall/SLACK_SETUP.md @@ -0,0 +1,42 @@ +# Slack Sync Setup Guide + +To sync on-call rotations to a Slack User Group, you need a **Slack User OAuth Token**. + +## Prerequisites +- You must be a **Workspace Admin** or **Owner** to manage User Groups. +- Your workspace must be on a paid Slack plan (Standard/Plus/Enterprise) to use User Groups. + +## Steps to Create Token + +1. **Create a Slack App** + - Go to [Slack API: Your Apps](https://api.slack.com/apps). + - Click **Create New App**. + - Select **From scratch**. + - Name it `On-Call Sync` and select your workspace. + +2. **Configure Scopes** + - In the left sidebar, click **OAuth & Permissions**. + - Scroll down to **User Token Scopes** (NOT Bot Token Scopes). + - Add the following scopes: + - `usergroups:read` (To list group members) + - `usergroups:write` (To add/remove members) + - `users:read` (To look up user details) + - `users:read.email` (To look up users by email) + + > [!IMPORTANT] + > **DO NOT** select scopes starting with `admin.` (e.g., `admin.usergroups:write`). These require an Enterprise Grid plan and will cause installation errors. Ensure you select the standard `usergroups:write`. + +3. **Install App & Get Token** + - Scroll up to **OAuth Tokens for Your Workspace**. + - Click **Install to Workspace**. + - Allow the permissions. + - Copy the **User OAuth Token**. It should start with `xoxp-`. + +## Usage + +Run the tool with your new token: + +```bash +export SLACK_TOKEN="xoxp-your-token-here" +go run ./synconcall --config=dev.oncall --slack-group=S0AAPDZBNQL +``` diff --git a/synconcall/go.mod b/synconcall/go.mod index 8bc36cd..037b8e4 100644 --- a/synconcall/go.mod +++ b/synconcall/go.mod @@ -16,6 +16,8 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/slack-go/slack v0.17.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect diff --git a/synconcall/go.sum b/synconcall/go.sum index 1b5e8fe..4683f73 100644 --- a/synconcall/go.sum +++ b/synconcall/go.sum @@ -27,8 +27,12 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= +github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/synconcall/main.go b/synconcall/main.go index f14d7df..7a9ce62 100644 --- a/synconcall/main.go +++ b/synconcall/main.go @@ -17,8 +17,13 @@ func main() { // Define flags configPath := flag.String("config", "", "Path to the oncall schedule file (required)") - groupEmail := flag.String("group", "", "Google Group email address to sync (required)") - adminUser := flag.String("admin-user", "", "Domain admin user email for impersonation (required)") + // Google Group flags + groupEmail := flag.String("group", "", "Google Group email address to sync") + adminUser := flag.String("admin-user", "", "Domain admin user email for impersonation (Google Group only)") + // Slack flags + slackGroup := flag.String("slack-group", "", "Slack User Group ID to sync") + slackToken := flag.String("slack-token", "", "Slack API Token (can also be set via SLACK_TOKEN env var)") + showHelp := flag.Bool("help", false, "Show usage information") flag.Parse() @@ -36,44 +41,74 @@ func main() { os.Exit(1) } - if *groupEmail == "" { - fmt.Fprintf(os.Stderr, "Error: --group flag is required\n\n") - printUsage() - os.Exit(1) - } + syncPerformed := false - if *adminUser == "" { - fmt.Fprintf(os.Stderr, "Error: --admin-user flag is required\n\n") - printUsage() - os.Exit(1) - } + // Google Group Sync + if *groupEmail != "" { + syncPerformed = true + fmt.Println("--- Starting Google Group Sync ---") - // Get credentials from environment - credentialsJSON := os.Getenv("GOOGLE_CREDENTIALS") - if credentialsJSON == "" { - fmt.Fprintf(os.Stderr, "Error: GOOGLE_CREDENTIALS environment variable not set\n") - fmt.Fprintf(os.Stderr, "Please set it to your Google service account JSON key content.\n") - os.Exit(1) - } + if *adminUser == "" { + fmt.Fprintf(os.Stderr, "Error: --admin-user flag is required for Google Group sync\n\n") + printUsage() + os.Exit(1) + } - // Validate credentials format - if err := ValidateCredentials([]byte(credentialsJSON)); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + // Get credentials from environment + credentialsJSON := os.Getenv("GOOGLE_CREDENTIALS") + if credentialsJSON == "" { + fmt.Fprintf(os.Stderr, "Error: GOOGLE_CREDENTIALS environment variable not set\n") + fmt.Fprintf(os.Stderr, "Please set it to your Google service account JSON key content.\n") + os.Exit(1) + } + + // Validate credentials format + if err := ValidateCredentials([]byte(credentialsJSON)); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Create Google Groups client + ctx := context.Background() + googleClient, err := NewGoogleGroupsClient(ctx, []byte(credentialsJSON), *adminUser) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: authentication failed - %v\n", err) + fmt.Fprintf(os.Stderr, "Please check that your service account has domain-wide delegation enabled.\n") + os.Exit(1) + } + + if err := runSync(*configPath, *groupEmail, googleClient); err != nil { + fmt.Fprintf(os.Stderr, "Error: Google Group sync failed - %v\n", err) + os.Exit(1) + } + fmt.Printf("--- Google Group Sync Completed ---\n\n") } - // Create Google Groups client - ctx := context.Background() - client, err := NewGoogleGroupsClient(ctx, []byte(credentialsJSON), *adminUser) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: authentication failed - %v\n", err) - fmt.Fprintf(os.Stderr, "Please check that your service account has domain-wide delegation enabled.\n") - os.Exit(1) + // Slack Sync + if *slackGroup != "" { + syncPerformed = true + fmt.Println("--- Starting Slack Sync ---") + + token := *slackToken + if token == "" { + token = os.Getenv("SLACK_TOKEN") + } + if token == "" { + fmt.Fprintf(os.Stderr, "Error: Slack token is required via --slack-token or SLACK_TOKEN env var\n") + os.Exit(1) + } + slackClient := NewSlackClient(token) + + if err := runSync(*configPath, *slackGroup, slackClient); err != nil { + fmt.Fprintf(os.Stderr, "Error: Slack sync failed - %v\n", err) + os.Exit(1) + } + fmt.Printf("--- Slack Sync Completed ---\n\n") } - // Run sync - if err := runSync(*configPath, *groupEmail, client); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + if !syncPerformed { + fmt.Fprintf(os.Stderr, "Error: No sync target specified. Provide --group (Google Groups) or --slack-group (Slack) or both.\n\n") + printUsage() os.Exit(1) } } @@ -125,35 +160,40 @@ func runSync(configPath string, groupEmail string, client GroupsClient) error { } func printUsage() { - fmt.Fprintf(os.Stderr, `synconcall - Sync oncall rotation to Google Group + fmt.Fprintf(os.Stderr, `synconcall - Sync oncall rotation to Google Group or Slack User Group Usage: - synconcall --config= --group= --admin-user= + synconcall --config= [--group= --admin-user=] [--slack-group= --slack-token=] Required Flags: --config string Path to the oncall schedule file (CSV format) + +Google Group Flags: --group string Google Group email address to sync --admin-user string Domain admin email for service account impersonation + (Requires GOOGLE_CREDENTIALS environment variable) + +Slack Flags: + --slack-group string + Slack User Group ID to sync + --slack-token string + Slack API Token (can also be set via SLACK_TOKEN env var) Optional Flags: --help Show this usage information -Environment Variables: - GOOGLE_CREDENTIALS (required) - Google service account JSON key content - Examples: - # GitHub Actions + # Google Groups (GitHub Actions) GOOGLE_CREDENTIALS="${{ secrets.GOOGLE_SERVICE_ACCOUNT }}" \ synconcall --config=dev.oncall --group=dev-oncall@bytebase.com --admin-user=admin@bytebase.com - # Local testing - GOOGLE_CREDENTIALS="$(cat service-account.json)" \ - synconcall --config=dev.oncall --group=dev-oncall@bytebase.com --admin-user=admin@bytebase.com + # Slack (GitHub Actions) + SLACK_TOKEN="${{ secrets.SLACK_TOKEN }}" \ + synconcall --config=dev.oncall --slack-group=S0123456789 Schedule File Format: CSV format with 3 columns: timestamp,primary_email,secondary_email diff --git a/synconcall/slack.go b/synconcall/slack.go new file mode 100644 index 0000000..41a32bf --- /dev/null +++ b/synconcall/slack.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/slack-go/slack" +) + +// SlackClient implements GroupsClient using Slack API +type SlackClient struct { + client *slack.Client +} + +// NewSlackClient creates a new Slack client +func NewSlackClient(token string) *SlackClient { + return &SlackClient{ + client: slack.New(token), + } +} + +// ListMembers retrieves all member email addresses from a Slack User Group +func (c *SlackClient) ListMembers(groupID string) ([]string, error) { + // Get members IDs of the group + memberIDs, err := c.client.GetUserGroupMembers(groupID) + if err != nil { + return nil, fmt.Errorf("failed to list slack group members: %w", err) + } + + if len(memberIDs) == 0 { + return []string{}, nil + } + + // To translate IDs to emails efficiently, it's often better to fetch all users + // if the group is large, or fetch individually if small. + // For robustness, let's fetch all users to build a map. + // Note: In very large workspaces, this might be slow. + // An alternative is to use GetUserInfo for each member if count is small. + // Let's assume on-call groups are relatively small (< 20 people), so fetching info one by one + // might actually be faster than fetching 10k users. + // However, Sync() calls `ListMembers` once. + // Let's try fetching info individually for now as it's safer for large workspaces, + // unless we hit rate limits. Parallelism could help here but let's keep it simple. + + var emails []string + for _, id := range memberIDs { + user, err := c.client.GetUserInfo(id) + if err != nil { + return nil, fmt.Errorf("failed to get user info for %s: %w", id, err) + } + // Skip bots or deleted users if necessary? Usually on-call are real users. + if user.IsBot || user.Deleted { + continue + } + emails = append(emails, user.Profile.Email) + } + + return emails, nil +} + +// AddMember adds a member to a Slack User Group +func (c *SlackClient) AddMember(groupID, memberEmail string) error { + // 1. Resolve email to User ID + user, err := c.client.GetUserByEmail(memberEmail) + if err != nil { + return fmt.Errorf("failed to resolve email %s to slack user: %w", memberEmail, err) + } + + // 2. Get current members + currentMemberIDs, err := c.client.GetUserGroupMembers(groupID) + if err != nil { + return fmt.Errorf("failed to get current group members: %w", err) + } + + // 3. Check if already present + for _, id := range currentMemberIDs { + if id == user.ID { + return nil // Already a member + } + } + + // 4. Add to list + newMemberIDs := append(currentMemberIDs, user.ID) + + // 5. Update group + _, err = c.client.UpdateUserGroupMembers(groupID, strings.Join(newMemberIDs, ",")) + if err != nil { + return fmt.Errorf("failed to update slack group members: %w", err) + } + + return nil +} + +// RemoveMember removes a member from a Slack User Group +func (c *SlackClient) RemoveMember(groupID, memberEmail string) error { + // 1. Resolve email to User ID + user, err := c.client.GetUserByEmail(memberEmail) + if err != nil { + return fmt.Errorf("failed to resolve email %s to slack user: %w", memberEmail, err) + } + + // 2. Get current members + currentMemberIDs, err := c.client.GetUserGroupMembers(groupID) + if err != nil { + return fmt.Errorf("failed to get current group members: %w", err) + } + + // 3. Remove from list + newMemberIDs := make([]string, 0, len(currentMemberIDs)) + found := false + for _, id := range currentMemberIDs { + if id == user.ID { + found = true + continue + } + newMemberIDs = append(newMemberIDs, id) + } + + if !found { + return nil // Not a member + } + + // 4. Update group + // Slack requires at least one member? + // If newMemberIDs is empty, this call might fail if Slack disables empty groups? + // The API doc doesn't explicitly forbid empty groups but the behavior might vary. + // Let's assume it's allowed or the group isn't meant to be empty. + _, err = c.client.UpdateUserGroupMembers(groupID, strings.Join(newMemberIDs, ",")) + if err != nil { + return fmt.Errorf("failed to update slack group members: %w", err) + } + + return nil +}