diff --git a/.github/workflows/sync-oncall.yml b/.github/workflows/sync-oncall.yml new file mode 100644 index 0000000..8f6bd45 --- /dev/null +++ b/.github/workflows/sync-oncall.yml @@ -0,0 +1,34 @@ +name: Sync Oncall to Google Group + +on: + schedule: + # Run daily at 01:00 UTC (rotation starts at 00:00 UTC) + - cron: '0 1 * * *' + push: + branches: + - main + workflow_dispatch: # Allow manual trigger + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Build synconcall + run: | + cd synconcall + go build -o synconcall + + - name: Sync oncall rotation + env: + GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} + run: | + cd synconcall + ./synconcall --config=../dev.oncall --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6ad5a88 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test + +on: + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run Go tests + run: | + cd synconcall + go test -v ./... + + - name: Validate dev.oncall schedule + run: | + cd synconcall + go run . validate --config=../dev.oncall diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e99ea19 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +synconcall/synconcall diff --git a/README.md b/README.md index ab5443c..f38416c 100644 --- a/README.md +++ b/README.md @@ -1 +1,135 @@ -# oncall \ No newline at end of file +# Oncall Rotation System + +Automated oncall rotation management system that syncs the current oncall schedule to a Google Group. + +## Overview + +This repository contains: +- **`dev.oncall`**: Oncall rotation schedule (CSV format) +- **`synconcall/`**: Go CLI tool that syncs current rotation to Google Group +- **GitHub Actions**: Automated daily sync workflow + +## Quick Start + +### Schedule File Format + +Edit `dev.oncall` to define your rotation schedule: + +```csv +2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com +2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com +2026-03-09T00:00:00Z,xz@bytebase.com,zp@bytebase.com +``` + +Each line: `start_time,primary_email,secondary_email` +- Timestamps in RFC3339 format (ISO 8601) +- Rotations in chronological order +- Each rotation period starts at the specified timestamp + +### How It Works + +1. GitHub Actions runs daily (configurable) +2. Reads `dev.oncall` to determine current rotation +3. Syncs Google Group membership to match current primary + secondary +4. Old oncall members are removed, current ones are added + +The sync is **declarative** - the group always reflects exactly who is currently oncall. + +## Setup + +**Service Account:** `dev-tools@bytebase-dev.iam.gserviceaccount.com` + +### 1. Create Service Account + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a service account +3. Download JSON key file +4. Enable domain-wide delegation + +### 2. Grant API Permissions + +In Google Workspace Admin Console: +1. Go to Security > API Controls > Domain-wide Delegation +2. Add the service account client ID +3. Grant OAuth scopes (comma-separated): + - `https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/admin.directory.group.member` + +**Note:** The service account uses domain-wide delegation to impersonate a domain admin user (`d@bytebase.com`) to access the Admin SDK. + +### 3. Configure GitHub Secrets + +Add repository secret: +- Name: `GOOGLE_SERVICE_ACCOUNT` +- Value: Contents of the service account JSON key file + +### 4. Customize Workflow + +Edit `.github/workflows/sync-oncall.yml`: +- Change cron schedule (default: daily at midnight UTC) +- Update group email if different from `dev-oncall@bytebase.com` + +## Manual Sync + +Build and run locally: + +```bash +cd synconcall +go build -o synconcall + +GOOGLE_CREDENTIALS="$(cat /path/to/service-account.json)" \ + ./synconcall --config=../dev.oncall --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com +``` + +## Updating the Schedule + +1. Edit `dev.oncall` to add new rotation periods +2. Commit and push changes +3. Next scheduled run will pick up the changes +4. Or trigger manually: Actions tab → "Sync Oncall to Google Group" → Run workflow + +## Project Structure + +``` +oncall/ +├── dev.oncall # Rotation schedule +├── synconcall/ # Sync tool +│ ├── main.go # CLI entry point +│ ├── schedule.go # Schedule parsing +│ ├── groups.go # Google API client +│ ├── sync.go # Sync logic +│ ├── *_test.go # Comprehensive tests +│ └── README.md # Tool documentation +├── .github/workflows/ +│ └── sync-oncall.yml # Automated sync workflow +└── docs/plans/ + └── 2026-01-23-oncall-sync-design.md # Design document +``` + +## Testing + +Run tests: +```bash +cd synconcall +go test -v +``` + +## Troubleshooting + +### Sync fails with authentication error +- Check service account has domain-wide delegation enabled +- Verify OAuth scopes are granted correctly +- Ensure JSON key is valid in GitHub secrets + +### Members not updating +- Check schedule file format (timestamps, emails) +- Verify current time falls within a rotation period +- Check GitHub Actions logs for errors + +### Wrong people in group +- Verify `dev.oncall` has correct rotation schedule +- Check timestamps are in chronological order +- Ensure timestamps use RFC3339 format with timezone + +## Design Documentation + +See [docs/plans/2026-01-23-oncall-sync-design.md](docs/plans/2026-01-23-oncall-sync-design.md) for detailed design decisions and architecture. \ No newline at end of file diff --git a/docs/plans/2026-01-23-oncall-sync-design.md b/docs/plans/2026-01-23-oncall-sync-design.md new file mode 100644 index 0000000..e94dcd1 --- /dev/null +++ b/docs/plans/2026-01-23-oncall-sync-design.md @@ -0,0 +1,286 @@ +# Oncall Sync Tool Design + +## Overview + +A Go CLI tool that synchronizes the current oncall rotation to a Google Group (dev-oncall@bytebase.com) based on a schedule file. The tool determines who is currently oncall and ensures the Google Group membership exactly matches (declarative set). + +## Architecture + +The program is a stateless CLI tool that: + +1. Reads the rotation schedule from a config file +2. Determines the current rotation based on current time +3. Authenticates with Google Groups API using a service account +4. Fetches current group membership +5. Reconciles membership to match the schedule (removes old, adds current) +6. Reports what changed + +### Project Structure + +``` +oncall/ +├── dev.oncall # Rotation schedule (top level) +└── synconcall/ # Go program directory + ├── main.go # CLI entry point, flags, orchestration + ├── schedule.go # Parse dev.oncall, find current rotation + ├── schedule_test.go # Tests for schedule parsing and rotation logic + ├── groups.go # Google Groups API client wrapper + ├── sync.go # Sync logic (calculate diff, apply changes) + ├── sync_test.go # Tests for sync logic + └── go.mod +``` + +## Schedule File Format + +### Format Specification + +- CSV format: `timestamp,primary_email,secondary_email` +- Timestamp in RFC3339 format (ISO 8601) +- Each line represents a rotation period starting at that timestamp +- Rotations ordered chronologically +- Current rotation = latest timestamp where timestamp <= current time + +### Example + +``` +2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com +2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com +2026-03-09T00:00:00Z,xz@bytebase.com,zp@bytebase.com +``` + +### Validation Rules + +1. Each line has exactly 3 comma-separated fields +2. Timestamp parses as valid RFC3339 +3. Timestamps are in ascending order +4. Emails look valid (basic format check: contains @ and .) +5. File has at least one rotation +6. No duplicate emails in same rotation + +### Code Structure + +```go +type Rotation struct { + StartTime time.Time + Primary string + Secondary string +} + +func ParseSchedule(filepath string) ([]Rotation, error) +func FindCurrentRotation(rotations []Rotation, now time.Time) (*Rotation, error) +func ValidateRotation(r Rotation) error +``` + +### Testing Approach + +Table-driven tests in `schedule_test.go`: + +**Parsing tests:** +- Valid schedule parsing +- Malformed timestamps +- Wrong field count +- Out of order timestamps +- Invalid email formats +- Empty file +- Duplicate emails in same rotation + +**FindCurrentRotation tests (with mocked time):** +- Current time during a rotation period → returns that rotation +- Current time exactly at rotation boundary → returns that rotation +- Current time before first rotation → error +- Current time after last rotation → returns last rotation +- Timezone handling edge cases + +## Google Groups API Integration + +### Authentication + +- Use Google Admin SDK Directory API +- Service account with domain-wide delegation +- Required scopes: + - `https://www.googleapis.com/auth/admin.directory.group` + - `https://www.googleapis.com/auth/admin.directory.group.member` +- Credentials provided via `GOOGLE_CREDENTIALS` environment variable (JSON content) + +### API Operations + +1. **List members** - `GET /admin/directory/v1/groups/{groupKey}/members` + - Get current membership of the group +2. **Add member** - `POST /admin/directory/v1/groups/{groupKey}/members` + - Add email with role="MEMBER" +3. **Remove member** - `DELETE /admin/directory/v1/groups/{groupKey}/members/{memberKey}` + - Remove by email address + +### Implementation + +- Use official `google.golang.org/api/admin/directory/v1` package +- Wrap in a `GroupsClient` interface for testability +- Mock interface for unit tests (no real API calls) + +```go +type GroupsClient interface { + ListMembers(groupEmail string) ([]string, error) + AddMember(groupEmail, memberEmail string) error + RemoveMember(groupEmail, memberEmail string) error +} +``` + +### Error Handling + +- Network failures → fail fast with clear error +- Authentication failures → fail with credential troubleshooting hints +- Group not found → fail with group name check +- Member already exists when adding → skip silently (idempotent) +- Member doesn't exist when removing → skip silently (idempotent) +- Rate limiting → fail fast (no retry for initial implementation) + +## Sync Logic + +### Reconciliation Process + +1. **Fetch current state:** + - Parse schedule file to get all rotations + - Find current rotation based on current time + - Query Google Group to get current members + +2. **Calculate desired state:** + - Desired members = [primary_email, secondary_email] from current rotation + - If primary and secondary are the same person, only include once + +3. **Calculate diff:** + - To add = desired members not currently in group + - To remove = current members not in desired set + +4. **Apply changes:** + - Execute removals first (clean up old oncall) + - Execute additions second (add new oncall) + - Each operation logs what it's doing + +5. **Report results:** + - Print summary of changes or "No changes needed" + - Exit 0 on success, non-zero on any failure + +### Code Structure + +```go +type SyncResult struct { + Added []string + Removed []string +} + +func Sync(groupEmail string, configPath string, client GroupsClient, now time.Time) (*SyncResult, error) +``` + +### Testing Approach + +Mock `GroupsClient` interface and test scenarios: +- Empty group → add both oncall people +- Correct members already → no changes +- Partial overlap → remove old, add new +- Complete mismatch → remove all, add both +- Same person as primary and secondary → add only once +- Verify correct API calls in correct order (remove then add) + +## CLI Interface + +### Command Usage + +```bash +# GitHub Actions +GOOGLE_CREDENTIALS="${{ secrets.GOOGLE_SERVICE_ACCOUNT }}" \ + synconcall --config=dev.oncall --group=dev-oncall@bytebase.com + +# Local testing +GOOGLE_CREDENTIALS="$(cat service-account.json)" \ + synconcall --config=dev.oncall --group=dev-oncall@bytebase.com +``` + +### Flags + +- `--config` (required) - Path to dev.oncall schedule file +- `--group` (required) - Google Group email address to sync +- `--help` - Show usage information + +### Environment Variables + +- `GOOGLE_CREDENTIALS` (required) - Service account JSON key content + +### Output Examples + +**Success with changes:** +``` +Reading schedule from: dev.oncall +Current rotation: 2026-01-12 - Primary: d@bytebase.com, Secondary: vh@bytebase.com +Syncing group: dev-oncall@bytebase.com + Removed: old-person@bytebase.com + Added: d@bytebase.com + Added: vh@bytebase.com +Sync completed successfully. +``` + +**Success with no changes:** +``` +Reading schedule from: dev.oncall +Current rotation: 2026-01-12 - Primary: d@bytebase.com, Secondary: vh@bytebase.com +Syncing group: dev-oncall@bytebase.com + No changes needed. +Sync completed successfully. +``` + +**Error example:** +``` +Error: failed to parse schedule: invalid timestamp format on line 3 +``` + +### Exit Codes + +- 0 = success +- 1 = any error + +## Error Handling & Edge Cases + +### Schedule File Errors + +- File not found → `Error: config file not found: {path}` +- Parse errors → `Error: invalid schedule format on line {n}: {reason}` +- No rotations found → `Error: schedule file is empty` +- Current time before first rotation → `Error: no active rotation (current time is before first rotation start)` + +### Google API Errors + +- Missing GOOGLE_CREDENTIALS → `Error: GOOGLE_CREDENTIALS environment variable not set` +- Invalid JSON in credentials → `Error: invalid service account credentials format` +- Auth failure → `Error: authentication failed - check service account has domain-wide delegation` +- Group not found → `Error: group not found: {email}` +- Permission denied → `Error: service account lacks permission to manage group members` +- Network/API failures → `Error: Google API request failed: {details}` + +### Edge Cases + +- Current rotation has same person as primary and secondary → Add them only once to the group +- Person already in group → Skip adding (idempotent) +- Person not in group when removing → Skip removing (idempotent) +- Empty group to start → Just add the two current oncall people +- Duplicate emails in schedule file → Validation error during parse + +### Logging + +- All output to stdout for normal operation +- Errors to stderr +- Clear, actionable error messages + +## Future Considerations + +Items deferred for later: + +1. Differentiate primary vs secondary oncall within Google Group + - Current implementation treats both as regular "Member" role + - Future: Could use custom member notes, roles, or separate groups + +2. Retry logic for transient API failures + - Current implementation fails fast + - Future: Add exponential backoff for network/rate limit errors + +3. Dry-run mode + - Current implementation applies changes immediately + - Future: Add `--dry-run` flag to preview changes diff --git a/synconcall/README.md b/synconcall/README.md new file mode 100644 index 0000000..93093ed --- /dev/null +++ b/synconcall/README.md @@ -0,0 +1,170 @@ +# synconcall + +A Go CLI tool that synchronizes the current oncall rotation to a Google Group based on a schedule file. + +## Features + +- Reads oncall rotation schedule from CSV file +- Determines current oncall based on current time +- Syncs Google Group membership to match current rotation (declarative set) +- Comprehensive error handling and validation +- Fully tested with unit tests + +## Prerequisites + +- Go 1.25.6 or later +- Google Workspace admin access +- Service account with domain-wide delegation +- Required Google API scopes: + - `https://www.googleapis.com/auth/admin.directory.group` + - `https://www.googleapis.com/auth/admin.directory.group.member` + +## Installation + +```bash +go build -o synconcall +``` + +## Usage + +### Command Line + +```bash +synconcall --config= --group= --admin-user= +``` + +**Required Flags:** +- `--config`: Path to the oncall schedule file (CSV format) +- `--group`: Google Group email address to sync +- `--admin-user`: Domain admin email for service account impersonation + +**Environment Variables:** +- `GOOGLE_CREDENTIALS` (required): Google service account JSON key content + +### Examples + +#### GitHub Actions + +```yaml +- name: Sync oncall rotation + env: + GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} + run: | + ./synconcall --config=dev.oncall --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com +``` + +#### Local Testing + +```bash +GOOGLE_CREDENTIALS="$(cat service-account.json)" \ + ./synconcall --config=dev.oncall --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com +``` + +## Schedule File Format + +CSV format with 3 columns: `timestamp,primary_email,secondary_email` + +- Timestamps in RFC3339 format (ISO 8601) +- Each line represents a rotation period starting at that timestamp +- Rotations must be in ascending chronological order + +**Example:** + +```csv +2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com +2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com +2026-03-09T00:00:00Z,xz@bytebase.com,zp@bytebase.com +``` + +## How It Works + +1. Parses the schedule file and validates format +2. Finds the current rotation based on current time +3. Authenticates with Google using service account credentials +4. Fetches current Google Group membership +5. Calculates diff between desired and current membership +6. Removes old members and adds current oncall people +7. Reports what changed + +The sync is **declarative** - the group membership will exactly match the current rotation. + +## Error Handling + +- **Schedule file errors**: Invalid format, missing file, bad timestamps, etc. +- **Google API errors**: Authentication failures, permission issues, network errors +- **Edge cases**: Time before first rotation, same person as primary and secondary, etc. + +All errors are reported to stderr with clear, actionable messages. The program exits with: +- `0` on success +- `1` on any error + +## Testing + +Run all tests: + +```bash +go test -v +``` + +Run specific tests: + +```bash +go test -v -run TestParseSchedule +go test -v -run TestFindCurrentRotation +go test -v -run TestSync +``` + +## Output Examples + +### Success with changes + +``` +Reading schedule from: dev.oncall +Current rotation: 2026-01-12 - Primary: d@bytebase.com, Secondary: vh@bytebase.com +Syncing group: dev-oncall@bytebase.com + Removed: old-person@bytebase.com + Added: d@bytebase.com + Added: vh@bytebase.com +Sync completed successfully. +``` + +### Success with no changes + +``` +Reading schedule from: dev.oncall +Current rotation: 2026-01-12 - Primary: d@bytebase.com, Secondary: vh@bytebase.com +Syncing group: dev-oncall@bytebase.com + No changes needed. +Sync completed successfully. +``` + +### Error example + +``` +Error: failed to parse schedule: invalid timestamp format on line 3 +``` + +## Service Account Setup + +1. Create a service account in Google Cloud Console +2. Download the JSON key file +3. Enable domain-wide delegation for the service account +4. Grant the following OAuth scopes in Google Workspace Admin: + - `https://www.googleapis.com/auth/admin.directory.group` + - `https://www.googleapis.com/auth/admin.directory.group.member` +5. Store the JSON key content in GitHub Secrets as `GOOGLE_SERVICE_ACCOUNT` + +## Project Structure + +``` +synconcall/ +├── main.go # CLI entry point and orchestration +├── schedule.go # Schedule parsing and rotation finding +├── schedule_test.go # Tests for schedule logic +├── groups.go # Google Groups API client +├── sync.go # Sync logic (diff calculation and reconciliation) +├── sync_test.go # Tests for sync logic +├── go.mod # Go module definition +├── go.sum # Go module checksums +└── README.md # This file +``` diff --git a/synconcall/go.mod b/synconcall/go.mod new file mode 100644 index 0000000..8bc36cd --- /dev/null +++ b/synconcall/go.mod @@ -0,0 +1,32 @@ +module github.com/bytebase/oncall/synconcall + +go 1.25.6 + +require google.golang.org/api v0.262.0 + +require ( + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + 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 + 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 + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/synconcall/go.sum b/synconcall/go.sum new file mode 100644 index 0000000..1b5e8fe --- /dev/null +++ b/synconcall/go.sum @@ -0,0 +1,75 @@ +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.262.0 h1:4B+3u8He2GwyN8St3Jhnd3XRHlIvc//sBmgHSp78oNY= +google.golang.org/api v0.262.0/go.mod h1:jNwmH8BgUBJ/VrUG6/lIl9YiildyLd09r9ZLHiQ6cGI= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 h1:vzOYHDZEHIsPYYnaSYo60AqHkJronSu0rzTz/s4quL0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/synconcall/groups.go b/synconcall/groups.go new file mode 100644 index 0000000..5e61a3b --- /dev/null +++ b/synconcall/groups.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "golang.org/x/oauth2/google" + "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" +) + +// GroupsClient defines the interface for Google Groups operations +type GroupsClient interface { + ListMembers(groupEmail string) ([]string, error) + AddMember(groupEmail, memberEmail string) error + RemoveMember(groupEmail, memberEmail string) error +} + +// GoogleGroupsClient implements GroupsClient using Google Admin SDK +type GoogleGroupsClient struct { + service *admin.Service + ctx context.Context +} + +// NewGoogleGroupsClient creates a new Google Groups client using service account credentials +// subject is the email of a domain admin user that the service account will impersonate +func NewGoogleGroupsClient(ctx context.Context, credentialsJSON []byte, subject string) (*GoogleGroupsClient, error) { + config, err := google.JWTConfigFromJSON(credentialsJSON, + "https://www.googleapis.com/auth/admin.directory.group", + "https://www.googleapis.com/auth/admin.directory.group.member") + if err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + + // Set the subject for domain-wide delegation + config.Subject = subject + + service, err := admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx))) + if err != nil { + return nil, fmt.Errorf("failed to create admin service: %w", err) + } + + return &GoogleGroupsClient{ + service: service, + ctx: ctx, + }, nil +} + +// ListMembers retrieves all member email addresses from a Google Group +func (c *GoogleGroupsClient) ListMembers(groupEmail string) ([]string, error) { + var members []string + pageToken := "" + + for { + call := c.service.Members.List(groupEmail) + if pageToken != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Do() + if err != nil { + return nil, fmt.Errorf("failed to list members: %w", err) + } + + for _, member := range resp.Members { + members = append(members, member.Email) + } + + pageToken = resp.NextPageToken + if pageToken == "" { + break + } + } + + return members, nil +} + +// AddMember adds a member to a Google Group with MEMBER role +func (c *GoogleGroupsClient) AddMember(groupEmail, memberEmail string) error { + member := &admin.Member{ + Email: memberEmail, + Role: "MEMBER", + } + + _, err := c.service.Members.Insert(groupEmail, member).Do() + if err != nil { + // Check if member already exists (idempotent) + if isAlreadyExistsError(err) { + return nil + } + return fmt.Errorf("failed to add member %s: %w", memberEmail, err) + } + + return nil +} + +// RemoveMember removes a member from a Google Group +func (c *GoogleGroupsClient) RemoveMember(groupEmail, memberEmail string) error { + err := c.service.Members.Delete(groupEmail, memberEmail).Do() + if err != nil { + // Check if member doesn't exist (idempotent) + if isNotFoundError(err) { + return nil + } + return fmt.Errorf("failed to remove member %s: %w", memberEmail, err) + } + + return nil +} + +// isAlreadyExistsError checks if the error indicates the member already exists +func isAlreadyExistsError(err error) bool { + // Google API returns specific error messages for duplicate members + // This is a basic check - in production you'd want more robust error handling + if err == nil { + return false + } + errMsg := err.Error() + return contains(errMsg, "already exists") || contains(errMsg, "duplicate") || contains(errMsg, "Member already exists") +} + +// isNotFoundError checks if the error indicates the member was not found +func isNotFoundError(err error) bool { + if err == nil { + return false + } + errMsg := err.Error() + return contains(errMsg, "not found") || contains(errMsg, "404") || contains(errMsg, "Resource Not Found") +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && findInString(s, substr) +} + +func findInString(s, substr string) bool { + if len(substr) == 0 { + return true + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// ValidateCredentials validates that the credentials JSON is properly formatted +func ValidateCredentials(credentialsJSON []byte) error { + var creds map[string]interface{} + if err := json.Unmarshal(credentialsJSON, &creds); err != nil { + return fmt.Errorf("invalid service account credentials format: %w", err) + } + return nil +} diff --git a/synconcall/main.go b/synconcall/main.go new file mode 100644 index 0000000..f14d7df --- /dev/null +++ b/synconcall/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" +) + +func main() { + // Check for subcommand + if len(os.Args) > 1 && os.Args[1] == "validate" { + validateCommand() + return + } + + // 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)") + showHelp := flag.Bool("help", false, "Show usage information") + + flag.Parse() + + // Show help + if *showHelp { + printUsage() + os.Exit(0) + } + + // Validate required flags + if *configPath == "" { + fmt.Fprintf(os.Stderr, "Error: --config flag is required\n\n") + printUsage() + os.Exit(1) + } + + if *groupEmail == "" { + fmt.Fprintf(os.Stderr, "Error: --group flag is required\n\n") + printUsage() + os.Exit(1) + } + + if *adminUser == "" { + fmt.Fprintf(os.Stderr, "Error: --admin-user flag is required\n\n") + printUsage() + 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() + 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) + } + + // Run sync + if err := runSync(*configPath, *groupEmail, client); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func runSync(configPath string, groupEmail string, client GroupsClient) error { + fmt.Printf("Reading schedule from: %s\n", configPath) + + // Get current time + now := time.Now() + + // Parse schedule and find current rotation + rotations, err := ParseSchedule(configPath) + if err != nil { + return err + } + + currentRotation, err := FindCurrentRotation(rotations, now) + if err != nil { + return err + } + + fmt.Printf("Current rotation: %s - Primary: %s, Secondary: %s\n", + currentRotation.StartTime.Format("2006-01-02"), + currentRotation.Primary, + currentRotation.Secondary) + + // Sync group membership + fmt.Printf("Syncing group: %s\n", groupEmail) + + result, err := Sync(groupEmail, configPath, client, now) + if err != nil { + return err + } + + // Report results + if len(result.Removed) == 0 && len(result.Added) == 0 { + fmt.Println(" No changes needed.") + } else { + for _, member := range result.Removed { + fmt.Printf(" Removed: %s\n", member) + } + for _, member := range result.Added { + fmt.Printf(" Added: %s\n", member) + } + } + + fmt.Println("Sync completed successfully.") + return nil +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `synconcall - Sync oncall rotation to Google Group + +Usage: + synconcall --config= --group= --admin-user= + +Required Flags: + --config string + Path to the oncall schedule file (CSV format) + --group string + Google Group email address to sync + --admin-user string + Domain admin email for service account impersonation + +Optional Flags: + --help + Show this usage information + +Environment Variables: + GOOGLE_CREDENTIALS (required) + Google service account JSON key content + +Examples: + # 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 + +Schedule File Format: + CSV format with 3 columns: timestamp,primary_email,secondary_email + Timestamps in RFC3339 format (ISO 8601) + + Example: + 2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com + 2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com +`) +} + +func validateCommand() { + // Define flags for validate subcommand + validateFlags := flag.NewFlagSet("validate", flag.ExitOnError) + configPath := validateFlags.String("config", "", "Path to the oncall schedule file (required)") + + validateFlags.Parse(os.Args[2:]) + + if *configPath == "" { + fmt.Fprintf(os.Stderr, "Error: --config flag is required\n") + fmt.Fprintf(os.Stderr, "Usage: synconcall validate --config=\n") + os.Exit(1) + } + + // Parse and validate schedule + rotations, err := ParseSchedule(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "✗ Schedule validation failed: %v\n", err) + os.Exit(1) + } + + // Find current rotation (validates that schedule has valid time ranges) + now := time.Now() + currentRotation, err := FindCurrentRotation(rotations, now) + if err != nil { + fmt.Fprintf(os.Stderr, "✗ Schedule validation failed: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✓ Schedule is valid\n") + fmt.Printf(" Total rotations: %d\n", len(rotations)) + fmt.Printf(" Current rotation: %s - Primary: %s, Secondary: %s\n", + currentRotation.StartTime.Format("2006-01-02"), + currentRotation.Primary, + currentRotation.Secondary) +} diff --git a/synconcall/schedule.go b/synconcall/schedule.go new file mode 100644 index 0000000..1b4b052 --- /dev/null +++ b/synconcall/schedule.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" +) + +// Rotation represents an oncall rotation period +type Rotation struct { + StartTime time.Time + Primary string + Secondary string +} + +// ParseSchedule reads and parses the schedule file +func ParseSchedule(filepath string) ([]Rotation, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, fmt.Errorf("failed to open schedule file: %w", err) + } + defer file.Close() + + var rotations []Rotation + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines + if line == "" { + continue + } + + parts := strings.Split(line, ",") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid schedule format on line %d: expected 3 fields, got %d", lineNum, len(parts)) + } + + timestamp := strings.TrimSpace(parts[0]) + primary := strings.TrimSpace(parts[1]) + secondary := strings.TrimSpace(parts[2]) + + // Parse timestamp + startTime, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return nil, fmt.Errorf("invalid timestamp format on line %d: %w", lineNum, err) + } + + rotation := Rotation{ + StartTime: startTime, + Primary: primary, + Secondary: secondary, + } + + // Validate rotation + if err := ValidateRotation(rotation); err != nil { + return nil, fmt.Errorf("invalid rotation on line %d: %w", lineNum, err) + } + + // Check ascending order + if len(rotations) > 0 && !startTime.After(rotations[len(rotations)-1].StartTime) { + return nil, fmt.Errorf("timestamps not in ascending order on line %d", lineNum) + } + + rotations = append(rotations, rotation) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading schedule file: %w", err) + } + + if len(rotations) == 0 { + return nil, fmt.Errorf("schedule file is empty") + } + + return rotations, nil +} + +// ValidateRotation validates a single rotation entry +func ValidateRotation(r Rotation) error { + if !isValidEmail(r.Primary) { + return fmt.Errorf("invalid primary email: %s", r.Primary) + } + if !isValidEmail(r.Secondary) { + return fmt.Errorf("invalid secondary email: %s", r.Secondary) + } + return nil +} + +// isValidEmail performs basic email validation +func isValidEmail(email string) bool { + if email == "" { + return false + } + // Basic check: contains @ and at least one dot after @ + atIndex := strings.Index(email, "@") + if atIndex == -1 || atIndex == 0 || atIndex == len(email)-1 { + return false + } + dotIndex := strings.LastIndex(email, ".") + return dotIndex > atIndex && dotIndex < len(email)-1 +} + +// FindCurrentRotation finds the rotation active at the given time +func FindCurrentRotation(rotations []Rotation, now time.Time) (*Rotation, error) { + if len(rotations) == 0 { + return nil, fmt.Errorf("no rotations available") + } + + // Find the latest rotation where StartTime <= now + var current *Rotation + for i := range rotations { + if rotations[i].StartTime.After(now) { + break + } + current = &rotations[i] + } + + if current == nil { + return nil, fmt.Errorf("no active rotation (current time is before first rotation start)") + } + + return current, nil +} diff --git a/synconcall/schedule_test.go b/synconcall/schedule_test.go new file mode 100644 index 0000000..5868afb --- /dev/null +++ b/synconcall/schedule_test.go @@ -0,0 +1,397 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestParseSchedule(t *testing.T) { + tests := []struct { + name string + content string + wantErr bool + errContains string + validate func(t *testing.T, rotations []Rotation) + }{ + { + name: "valid schedule", + content: `2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com +2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com +2026-03-09T00:00:00Z,xz@bytebase.com,zp@bytebase.com`, + wantErr: false, + validate: func(t *testing.T, rotations []Rotation) { + if len(rotations) != 3 { + t.Errorf("expected 3 rotations, got %d", len(rotations)) + } + if rotations[0].Primary != "d@bytebase.com" { + t.Errorf("expected primary d@bytebase.com, got %s", rotations[0].Primary) + } + if rotations[0].Secondary != "vh@bytebase.com" { + t.Errorf("expected secondary vh@bytebase.com, got %s", rotations[0].Secondary) + } + }, + }, + { + name: "empty file", + content: "", + wantErr: true, + errContains: "empty", + }, + { + name: "only whitespace", + content: " \n \n ", + wantErr: true, + errContains: "empty", + }, + { + name: "wrong field count - too few", + content: "2026-01-12T00:00:00Z,d@bytebase.com", + wantErr: true, + errContains: "expected 3 fields", + }, + { + name: "wrong field count - too many", + content: "2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com,extra@bytebase.com", + wantErr: true, + errContains: "expected 3 fields", + }, + { + name: "malformed timestamp", + content: "2026-01-12,d@bytebase.com,vh@bytebase.com", + wantErr: true, + errContains: "invalid timestamp format", + }, + { + name: "invalid timestamp format", + content: "not-a-date,d@bytebase.com,vh@bytebase.com", + wantErr: true, + errContains: "invalid timestamp format", + }, + { + name: "timestamps not in ascending order", + content: `2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com +2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com`, + wantErr: true, + errContains: "not in ascending order", + }, + { + name: "duplicate timestamps", + content: `2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com +2026-01-12T00:00:00Z,vh@bytebase.com,xz@bytebase.com`, + wantErr: true, + errContains: "not in ascending order", + }, + { + name: "invalid primary email - no @", + content: "2026-01-12T00:00:00Z,invalid-email,vh@bytebase.com", + wantErr: true, + errContains: "invalid primary email", + }, + { + name: "invalid secondary email - no domain", + content: "2026-01-12T00:00:00Z,d@bytebase.com,vh@", + wantErr: true, + errContains: "invalid secondary email", + }, + { + name: "invalid email - @ at start", + content: "2026-01-12T00:00:00Z,@bytebase.com,vh@bytebase.com", + wantErr: true, + errContains: "invalid primary email", + }, + { + name: "invalid email - @ at end", + content: "2026-01-12T00:00:00Z,d@bytebase.com,vh@", + wantErr: true, + errContains: "invalid secondary email", + }, + { + name: "empty email", + content: "2026-01-12T00:00:00Z,,vh@bytebase.com", + wantErr: true, + errContains: "invalid primary email", + }, + { + name: "valid schedule with empty lines", + content: `2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com + +2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com`, + wantErr: false, + validate: func(t *testing.T, rotations []Rotation) { + if len(rotations) != 2 { + t.Errorf("expected 2 rotations, got %d", len(rotations)) + } + }, + }, + { + name: "valid schedule with whitespace around fields", + content: " 2026-01-12T00:00:00Z , d@bytebase.com , vh@bytebase.com ", + wantErr: false, + validate: func(t *testing.T, rotations []Rotation) { + if rotations[0].Primary != "d@bytebase.com" { + t.Errorf("expected trimmed primary, got %s", rotations[0].Primary) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "schedule.csv") + if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + rotations, err := ParseSchedule(tmpFile) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } else if tt.errContains != "" && !containsString(err.Error(), tt.errContains) { + t.Errorf("expected error containing %q, got %q", tt.errContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if tt.validate != nil { + tt.validate(t, rotations) + } + } + }) + } +} + +func TestParseSchedule_FileNotFound(t *testing.T) { + _, err := ParseSchedule("/nonexistent/file.csv") + if err == nil { + t.Error("expected error for nonexistent file") + } + if !containsString(err.Error(), "failed to open schedule file") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestFindCurrentRotation(t *testing.T) { + // Fixed test rotations + rotations := []Rotation{ + { + StartTime: mustParseTime("2026-01-12T00:00:00Z"), + Primary: "d@bytebase.com", + Secondary: "vh@bytebase.com", + }, + { + StartTime: mustParseTime("2026-02-09T00:00:00Z"), + Primary: "vh@bytebase.com", + Secondary: "xz@bytebase.com", + }, + { + StartTime: mustParseTime("2026-03-09T00:00:00Z"), + Primary: "xz@bytebase.com", + Secondary: "zp@bytebase.com", + }, + } + + tests := []struct { + name string + now string + wantPrimary string + wantErr bool + errContains string + }{ + { + name: "current time in first rotation", + now: "2026-01-15T12:00:00Z", + wantPrimary: "d@bytebase.com", + wantErr: false, + }, + { + name: "current time in second rotation", + now: "2026-02-20T12:00:00Z", + wantPrimary: "vh@bytebase.com", + wantErr: false, + }, + { + name: "current time in third rotation", + now: "2026-03-15T12:00:00Z", + wantPrimary: "xz@bytebase.com", + wantErr: false, + }, + { + name: "current time exactly at rotation boundary", + now: "2026-02-09T00:00:00Z", + wantPrimary: "vh@bytebase.com", + wantErr: false, + }, + { + name: "current time before first rotation", + now: "2026-01-01T00:00:00Z", + wantErr: true, + errContains: "before first rotation start", + }, + { + name: "current time after last rotation", + now: "2026-12-31T23:59:59Z", + wantPrimary: "xz@bytebase.com", + wantErr: false, + }, + { + name: "current time one second before rotation change", + now: "2026-02-08T23:59:59Z", + wantPrimary: "d@bytebase.com", + wantErr: false, + }, + { + name: "current time one second after rotation start", + now: "2026-02-09T00:00:01Z", + wantPrimary: "vh@bytebase.com", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := mustParseTime(tt.now) + rotation, err := FindCurrentRotation(rotations, now) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } else if tt.errContains != "" && !containsString(err.Error(), tt.errContains) { + t.Errorf("expected error containing %q, got %q", tt.errContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if rotation == nil { + t.Fatal("expected rotation, got nil") + } + if rotation.Primary != tt.wantPrimary { + t.Errorf("expected primary %s, got %s", tt.wantPrimary, rotation.Primary) + } + } + }) + } +} + +func TestFindCurrentRotation_EmptyRotations(t *testing.T) { + now := mustParseTime("2026-01-15T12:00:00Z") + _, err := FindCurrentRotation([]Rotation{}, now) + if err == nil { + t.Error("expected error for empty rotations") + } + if !containsString(err.Error(), "no rotations available") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestValidateRotation(t *testing.T) { + tests := []struct { + name string + rotation Rotation + wantErr bool + errContains string + }{ + { + name: "valid rotation", + rotation: Rotation{ + StartTime: time.Now(), + Primary: "d@bytebase.com", + Secondary: "vh@bytebase.com", + }, + wantErr: false, + }, + { + name: "invalid primary email", + rotation: Rotation{ + StartTime: time.Now(), + Primary: "invalid", + Secondary: "vh@bytebase.com", + }, + wantErr: true, + errContains: "invalid primary email", + }, + { + name: "invalid secondary email", + rotation: Rotation{ + StartTime: time.Now(), + Primary: "d@bytebase.com", + Secondary: "invalid", + }, + wantErr: true, + errContains: "invalid secondary email", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRotation(tt.rotation) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } else if tt.errContains != "" && !containsString(err.Error(), tt.errContains) { + t.Errorf("expected error containing %q, got %q", tt.errContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestIsValidEmail(t *testing.T) { + tests := []struct { + email string + valid bool + }{ + {"d@bytebase.com", true}, + {"user@example.org", true}, + {"test.user@sub.domain.com", true}, + {"invalid", false}, + {"@bytebase.com", false}, + {"user@", false}, + {"user", false}, + {"", false}, + {"user@domain", false}, // no dot after @ + {"user.domain.com", false}, // no @ + } + + for _, tt := range tests { + t.Run(tt.email, func(t *testing.T) { + result := isValidEmail(tt.email) + if result != tt.valid { + t.Errorf("isValidEmail(%q) = %v, want %v", tt.email, result, tt.valid) + } + }) + } +} + +// Helper functions + +func mustParseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} + +func containsString(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/synconcall/sync.go b/synconcall/sync.go new file mode 100644 index 0000000..a916b74 --- /dev/null +++ b/synconcall/sync.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "time" +) + +// SyncResult contains the results of a sync operation +type SyncResult struct { + Added []string + Removed []string +} + +// Sync reconciles the Google Group membership with the current oncall rotation +func Sync(groupEmail string, configPath string, client GroupsClient, now time.Time) (*SyncResult, error) { + // Parse schedule + rotations, err := ParseSchedule(configPath) + if err != nil { + return nil, err + } + + // Find current rotation + currentRotation, err := FindCurrentRotation(rotations, now) + if err != nil { + return nil, err + } + + // Get desired members (deduplicated in case primary == secondary) + desiredMembers := uniqueMembers([]string{currentRotation.Primary, currentRotation.Secondary}) + + // Get current group members + currentMembers, err := client.ListMembers(groupEmail) + if err != nil { + return nil, fmt.Errorf("failed to list group members: %w", err) + } + + // Calculate diff + toAdd := difference(desiredMembers, currentMembers) + toRemove := difference(currentMembers, desiredMembers) + + result := &SyncResult{ + Added: make([]string, 0), + Removed: make([]string, 0), + } + + // Remove members first + for _, member := range toRemove { + if err := client.RemoveMember(groupEmail, member); err != nil { + return nil, err + } + result.Removed = append(result.Removed, member) + } + + // Add new members + for _, member := range toAdd { + if err := client.AddMember(groupEmail, member); err != nil { + return nil, err + } + result.Added = append(result.Added, member) + } + + return result, nil +} + +// uniqueMembers returns a deduplicated list of members +func uniqueMembers(members []string) []string { + seen := make(map[string]bool) + result := make([]string, 0, len(members)) + + for _, member := range members { + if !seen[member] { + seen[member] = true + result = append(result, member) + } + } + + return result +} + +// difference returns elements in a that are not in b +func difference(a, b []string) []string { + bSet := make(map[string]bool) + for _, item := range b { + bSet[item] = true + } + + result := make([]string, 0) + for _, item := range a { + if !bSet[item] { + result = append(result, item) + } + } + + return result +} diff --git a/synconcall/sync_test.go b/synconcall/sync_test.go new file mode 100644 index 0000000..9af7bf2 --- /dev/null +++ b/synconcall/sync_test.go @@ -0,0 +1,400 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// MockGroupsClient is a mock implementation of GroupsClient for testing +type MockGroupsClient struct { + members map[string][]string // groupEmail -> list of members + addedCalls []string // track added members + removedCalls []string // track removed members + listError error + addError error + removeError error +} + +func NewMockGroupsClient() *MockGroupsClient { + return &MockGroupsClient{ + members: make(map[string][]string), + addedCalls: make([]string, 0), + removedCalls: make([]string, 0), + } +} + +func (m *MockGroupsClient) ListMembers(groupEmail string) ([]string, error) { + if m.listError != nil { + return nil, m.listError + } + members, ok := m.members[groupEmail] + if !ok { + return []string{}, nil + } + // Return a copy to avoid mutation + result := make([]string, len(members)) + copy(result, members) + return result, nil +} + +func (m *MockGroupsClient) AddMember(groupEmail, memberEmail string) error { + if m.addError != nil { + return m.addError + } + m.addedCalls = append(m.addedCalls, memberEmail) + if m.members[groupEmail] == nil { + m.members[groupEmail] = make([]string, 0) + } + m.members[groupEmail] = append(m.members[groupEmail], memberEmail) + return nil +} + +func (m *MockGroupsClient) RemoveMember(groupEmail, memberEmail string) error { + if m.removeError != nil { + return m.removeError + } + m.removedCalls = append(m.removedCalls, memberEmail) + members := m.members[groupEmail] + for i, member := range members { + if member == memberEmail { + m.members[groupEmail] = append(members[:i], members[i+1:]...) + break + } + } + return nil +} + +func TestSync(t *testing.T) { + // Create a test schedule file + scheduleContent := `2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com +2026-02-09T00:00:00Z,vh@bytebase.com,xz@bytebase.com +2026-03-09T00:00:00Z,xz@bytebase.com,zp@bytebase.com` + + tmpDir := t.TempDir() + scheduleFile := filepath.Join(tmpDir, "schedule.csv") + if err := os.WriteFile(scheduleFile, []byte(scheduleContent), 0644); err != nil { + t.Fatalf("failed to create schedule file: %v", err) + } + + tests := []struct { + name string + currentTime string + initialMembers []string + expectedAdded []string + expectedRemoved []string + wantErr bool + }{ + { + name: "empty group - add both oncall", + currentTime: "2026-01-15T12:00:00Z", + initialMembers: []string{}, + expectedAdded: []string{"d@bytebase.com", "vh@bytebase.com"}, + expectedRemoved: []string{}, + wantErr: false, + }, + { + name: "correct members already - no changes", + currentTime: "2026-01-15T12:00:00Z", + initialMembers: []string{"d@bytebase.com", "vh@bytebase.com"}, + expectedAdded: []string{}, + expectedRemoved: []string{}, + wantErr: false, + }, + { + name: "partial overlap - remove old, add new", + currentTime: "2026-02-15T12:00:00Z", + initialMembers: []string{"d@bytebase.com", "vh@bytebase.com"}, + expectedAdded: []string{"xz@bytebase.com"}, + expectedRemoved: []string{"d@bytebase.com"}, + wantErr: false, + }, + { + name: "complete mismatch - remove all, add both", + currentTime: "2026-03-15T12:00:00Z", + initialMembers: []string{"d@bytebase.com", "vh@bytebase.com"}, + expectedAdded: []string{"xz@bytebase.com", "zp@bytebase.com"}, + expectedRemoved: []string{"d@bytebase.com", "vh@bytebase.com"}, + wantErr: false, + }, + { + name: "extra members in group - remove them", + currentTime: "2026-01-15T12:00:00Z", + initialMembers: []string{"d@bytebase.com", "vh@bytebase.com", "old@bytebase.com", "extra@bytebase.com"}, + expectedAdded: []string{}, + expectedRemoved: []string{"old@bytebase.com", "extra@bytebase.com"}, + wantErr: false, + }, + { + name: "rotation change at boundary", + currentTime: "2026-02-09T00:00:00Z", + initialMembers: []string{"d@bytebase.com", "vh@bytebase.com"}, + expectedAdded: []string{"xz@bytebase.com"}, + expectedRemoved: []string{"d@bytebase.com"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := NewMockGroupsClient() + groupEmail := "dev-oncall@bytebase.com" + + // Set up initial members + mock.members[groupEmail] = tt.initialMembers + + // Parse time + now, err := time.Parse(time.RFC3339, tt.currentTime) + if err != nil { + t.Fatalf("failed to parse time: %v", err) + } + + // Run sync + result, err := Sync(groupEmail, scheduleFile, mock, now) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check results + if !stringSlicesEqual(result.Added, tt.expectedAdded) { + t.Errorf("added mismatch: got %v, want %v", result.Added, tt.expectedAdded) + } + if !stringSlicesEqual(result.Removed, tt.expectedRemoved) { + t.Errorf("removed mismatch: got %v, want %v", result.Removed, tt.expectedRemoved) + } + + // Verify mock was called correctly + if !stringSlicesEqual(mock.addedCalls, tt.expectedAdded) { + t.Errorf("AddMember calls mismatch: got %v, want %v", mock.addedCalls, tt.expectedAdded) + } + if !stringSlicesEqual(mock.removedCalls, tt.expectedRemoved) { + t.Errorf("RemoveMember calls mismatch: got %v, want %v", mock.removedCalls, tt.expectedRemoved) + } + }) + } +} + +func TestSync_SamePrimaryAndSecondary(t *testing.T) { + // Create a schedule where primary and secondary are the same person + scheduleContent := `2026-01-12T00:00:00Z,d@bytebase.com,d@bytebase.com` + + tmpDir := t.TempDir() + scheduleFile := filepath.Join(tmpDir, "schedule.csv") + if err := os.WriteFile(scheduleFile, []byte(scheduleContent), 0644); err != nil { + t.Fatalf("failed to create schedule file: %v", err) + } + + mock := NewMockGroupsClient() + groupEmail := "dev-oncall@bytebase.com" + now, _ := time.Parse(time.RFC3339, "2026-01-15T12:00:00Z") + + result, err := Sync(groupEmail, scheduleFile, mock, now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should only add the person once + if len(result.Added) != 1 || result.Added[0] != "d@bytebase.com" { + t.Errorf("expected to add d@bytebase.com once, got %v", result.Added) + } + + // Verify member was added only once + members, _ := mock.ListMembers(groupEmail) + if len(members) != 1 { + t.Errorf("expected 1 member in group, got %d", len(members)) + } +} + +func TestSync_ErrorCases(t *testing.T) { + scheduleContent := `2026-01-12T00:00:00Z,d@bytebase.com,vh@bytebase.com` + tmpDir := t.TempDir() + scheduleFile := filepath.Join(tmpDir, "schedule.csv") + if err := os.WriteFile(scheduleFile, []byte(scheduleContent), 0644); err != nil { + t.Fatalf("failed to create schedule file: %v", err) + } + + now, _ := time.Parse(time.RFC3339, "2026-01-15T12:00:00Z") + groupEmail := "dev-oncall@bytebase.com" + + t.Run("list members error", func(t *testing.T) { + mock := NewMockGroupsClient() + mock.listError = fmt.Errorf("API error") + + _, err := Sync(groupEmail, scheduleFile, mock, now) + if err == nil { + t.Error("expected error, got nil") + } + }) + + t.Run("add member error", func(t *testing.T) { + mock := NewMockGroupsClient() + mock.addError = fmt.Errorf("API error") + + _, err := Sync(groupEmail, scheduleFile, mock, now) + if err == nil { + t.Error("expected error, got nil") + } + }) + + t.Run("remove member error", func(t *testing.T) { + mock := NewMockGroupsClient() + mock.members[groupEmail] = []string{"old@bytebase.com"} + mock.removeError = fmt.Errorf("API error") + + _, err := Sync(groupEmail, scheduleFile, mock, now) + if err == nil { + t.Error("expected error, got nil") + } + }) + + t.Run("invalid schedule file", func(t *testing.T) { + mock := NewMockGroupsClient() + _, err := Sync(groupEmail, "/nonexistent/file.csv", mock, now) + if err == nil { + t.Error("expected error for invalid file") + } + }) + + t.Run("time before first rotation", func(t *testing.T) { + mock := NewMockGroupsClient() + beforeTime, _ := time.Parse(time.RFC3339, "2026-01-01T00:00:00Z") + _, err := Sync(groupEmail, scheduleFile, mock, beforeTime) + if err == nil { + t.Error("expected error for time before first rotation") + } + }) +} + +func TestUniqueMembers(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "no duplicates", + input: []string{"a@test.com", "b@test.com"}, + expected: []string{"a@test.com", "b@test.com"}, + }, + { + name: "with duplicates", + input: []string{"a@test.com", "a@test.com"}, + expected: []string{"a@test.com"}, + }, + { + name: "empty list", + input: []string{}, + expected: []string{}, + }, + { + name: "all same", + input: []string{"a@test.com", "a@test.com", "a@test.com"}, + expected: []string{"a@test.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := uniqueMembers(tt.input) + if !stringSlicesEqual(result, tt.expected) { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +} + +func TestDifference(t *testing.T) { + tests := []struct { + name string + a []string + b []string + expected []string + }{ + { + name: "no overlap", + a: []string{"a@test.com", "b@test.com"}, + b: []string{"c@test.com", "d@test.com"}, + expected: []string{"a@test.com", "b@test.com"}, + }, + { + name: "complete overlap", + a: []string{"a@test.com", "b@test.com"}, + b: []string{"a@test.com", "b@test.com"}, + expected: []string{}, + }, + { + name: "partial overlap", + a: []string{"a@test.com", "b@test.com", "c@test.com"}, + b: []string{"b@test.com"}, + expected: []string{"a@test.com", "c@test.com"}, + }, + { + name: "empty a", + a: []string{}, + b: []string{"a@test.com"}, + expected: []string{}, + }, + { + name: "empty b", + a: []string{"a@test.com"}, + b: []string{}, + expected: []string{"a@test.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := difference(tt.a, tt.b) + if !stringSlicesEqual(result, tt.expected) { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +} + +// Helper function to compare string slices (order-independent for sets) +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + // For empty slices + if len(a) == 0 { + return true + } + + // Create frequency maps + aMap := make(map[string]int) + bMap := make(map[string]int) + + for _, s := range a { + aMap[s]++ + } + for _, s := range b { + bMap[s]++ + } + + // Compare maps + for k, v := range aMap { + if bMap[k] != v { + return false + } + } + for k, v := range bMap { + if aMap[k] != v { + return false + } + } + + return true +}