Skip to content

Commit dde1a86

Browse files
committed
Initial commit
0 parents  commit dde1a86

15 files changed

Lines changed: 1584 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
tags: [ 'v*' ]
7+
pull_request:
8+
branches: [ main ]
9+
10+
env:
11+
REGISTRY: ghcr.io
12+
IMAGE_NAME: ${{ github.repository }}
13+
14+
jobs:
15+
build-and-push:
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: read
19+
packages: write
20+
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v4
24+
25+
- name: Log in to the Container registry
26+
if: github.event_name != 'pull_request'
27+
uses: docker/login-action@v3
28+
with:
29+
registry: ${{ env.REGISTRY }}
30+
username: ${{ github.actor }}
31+
password: ${{ secrets.GITHUB_TOKEN }}
32+
33+
- name: Extract metadata (tags, labels) for Docker
34+
id: meta
35+
uses: docker/metadata-action@v5
36+
with:
37+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
38+
tags: |
39+
type=raw,value=latest,enable={{github.event_name != 'pull_request'}}
40+
type=semver,pattern={{version}}
41+
type=semver,pattern={{major}}.{{minor}}
42+
43+
- name: Build and push Docker image
44+
uses: docker/build-push-action@v5
45+
with:
46+
context: .
47+
push: ${{ github.event_name != 'pull_request' }}
48+
tags: ${{ steps.meta.outputs.tags }}
49+
labels: ${{ steps.meta.outputs.labels }}

.gitignore

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
### Go ###
2+
# If you prefer the allow list template instead of the deny list, see community template:
3+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
4+
#
5+
# Binaries for programs and plugins
6+
*.exe
7+
*.exe~
8+
*.dll
9+
*.so
10+
*.dylib
11+
12+
# Test binary, built with `go test -c`
13+
*.test
14+
15+
# Output of the go coverage tool, specifically when used with LiteIDE
16+
*.out
17+
18+
# Dependency directories (remove the comment below to include it)
19+
# vendor/
20+
21+
# Go workspace file
22+
go.work
23+
24+
# Project Specific
25+
reference/
26+
27+
# Environment
28+
.env
29+
30+
# Binaries
31+
/hafnium
32+
33+
# Config
34+
mappings.toml

Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Build stage
2+
FROM golang:1.24-alpine AS builder
3+
4+
WORKDIR /app
5+
6+
COPY go.mod go.sum ./
7+
RUN go mod download
8+
9+
COPY . .
10+
11+
RUN CGO_ENABLED=0 GOOS=linux go build -o hafnium ./cmd/hafnium
12+
13+
# Final stage
14+
FROM alpine:latest
15+
16+
RUN apk --no-cache add ca-certificates
17+
18+
WORKDIR /root/
19+
20+
COPY --from=builder /app/hafnium .
21+
22+
# Example command, but should be overridden or use env vars
23+
CMD ["./hafnium"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Owl Corp
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Hafnium
2+
3+
Hafnium is a standalone Go daemon that synchronizes Keycloak role memberships with GitHub organization and team memberships. It replaces the legacy Discord bot implementation with a more efficient, concurrent, and observable service.
4+
5+
## Features
6+
7+
- **Concurrent Sync:** Optimized data fetching from Keycloak and GitHub using Go's concurrency primitives.
8+
- **Organization Sync:** Automatically adds/removes members from the GitHub organization based on Keycloak linked identities.
9+
- **Team Sync:** Synchronizes Keycloak roles to GitHub team memberships using a configurable mapping.
10+
- **Invitation Management:**
11+
- Sends Discord DMs for new organization invitations.
12+
- Automatically cleans up and notifies users about failed/expired invitations.
13+
- **Observability:**
14+
- Prometheus metrics (`/metrics`) for monitoring sync performance and state.
15+
- Structured logging for easy troubleshooting.
16+
- **Configuration:** Flexible configuration via environment variables and TOML mapping files.
17+
18+
## Configuration
19+
20+
Hafnium is configured via environment variables (prefixed with `HAFNIUM_`), CLI flags, or a configuration file.
21+
22+
### CLI Help
23+
24+
For a full list of configuration options and flags, run:
25+
26+
```bash
27+
./hafnium --help
28+
```
29+
30+
### Keycloak & GitHub URLs
31+
32+
- **GitHub Invitation Link:** Automatically generated as `https://github.com/orgs/{org}/invitation`.
33+
- **Keycloak Account Link:** Automatically generated as `https://id.pydis.wtf/realms/{realm}/account/account-security/linked-accounts`.
34+
35+
### Role Mapping (`mappings.toml`)
36+
37+
Create a TOML file to map Keycloak roles to GitHub team slugs and Discord roles:
38+
39+
```toml
40+
[mappings]
41+
helpers = { github_team_slug = "helpers", discord_role_id = 267630620367257601 }
42+
devops = { github_team_slug = "devops", discord_role_id = 409416496733880320 }
43+
```
44+
45+
## Metrics
46+
47+
Hafnium exposes the following Prometheus metrics:
48+
49+
- `hafnium_org_members_total`: Total organization members.
50+
- `hafnium_team_members_total{team="slug"}`: Total members in a specific team.
51+
- `hafnium_keycloak_users_total`: Total users found in Keycloak.
52+
- `hafnium_org_added_total`: Counter for users added to the org.
53+
- `hafnium_org_removed_total`: Counter for users removed from the org.
54+
- `hafnium_team_added_total{team="slug"}`: Counter for users added to a team.
55+
- `hafnium_team_removed_total{team="slug"}`: Counter for users removed from a team.
56+
- `hafnium_sync_duration_seconds`: Histogram of sync task duration.
57+
58+
## Development
59+
60+
### Building Locally
61+
62+
```bash
63+
go build -o hafnium ./cmd/hafnium
64+
```
65+
66+
### Running with Docker
67+
68+
```bash
69+
docker build -t hafnium .
70+
docker run --env-file .env hafnium
71+
```
72+
73+
### CLI Help
74+
75+
You can view usage information and configuration details by running:
76+
77+
```bash
78+
./hafnium --help
79+
```
80+
81+
## License
82+
83+
[MIT](LICENSE)

cmd/hafnium/main.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log"
6+
"net/http"
7+
"os"
8+
"os/signal"
9+
"strings"
10+
"syscall"
11+
"time"
12+
13+
"github.com/prometheus/client_golang/prometheus/promhttp"
14+
"github.com/spf13/cobra"
15+
"github.com/spf13/viper"
16+
17+
"github.com/owl-corp/hafnium/config"
18+
"github.com/owl-corp/hafnium/pkg/discord"
19+
"github.com/owl-corp/hafnium/pkg/github"
20+
"github.com/owl-corp/hafnium/pkg/keycloak"
21+
"github.com/owl-corp/hafnium/pkg/sync"
22+
)
23+
24+
var (
25+
v = viper.New()
26+
)
27+
28+
func main() {
29+
rootCmd := &cobra.Command{
30+
Use: "hafnium",
31+
Short: "Keycloak to GitHub Sync Daemon",
32+
Long: "A standalone daemon that synchronizes Keycloak role memberships with GitHub organization and team memberships.",
33+
Run: run,
34+
}
35+
36+
config.BindFlags(rootCmd.Flags(), v)
37+
38+
// Environment variable setup
39+
v.SetEnvPrefix("HAFNIUM")
40+
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
41+
v.AutomaticEnv()
42+
43+
if err := rootCmd.Execute(); err != nil {
44+
os.Exit(1)
45+
}
46+
}
47+
48+
func run(cmd *cobra.Command, args []string) {
49+
log.Println("Starting Hafnium...")
50+
51+
cfg, err := config.LoadConfig(v)
52+
if err != nil {
53+
log.Fatalf("Configuration error: %v", err)
54+
}
55+
56+
kcClient := keycloak.NewClient(cfg.Keycloak.URL, cfg.Keycloak.Realm, cfg.Keycloak.Username, cfg.Keycloak.Password)
57+
ghClient := github.NewClient(cfg.Github.Token, cfg.Github.Org)
58+
dcClient, err := discord.NewClient(cfg.Discord.Token)
59+
if err != nil {
60+
log.Fatalf("Failed to initialize Discord client: %v", err)
61+
}
62+
defer dcClient.Close()
63+
64+
engine := sync.NewEngine(cfg, kcClient, ghClient, dcClient)
65+
66+
// Metrics server
67+
go func() {
68+
log.Printf("Starting metrics server on %s", cfg.Metrics.Addr)
69+
http.Handle("/metrics", promhttp.Handler())
70+
if err := http.ListenAndServe(cfg.Metrics.Addr, nil); err != nil {
71+
log.Printf("Metrics server error: %v", err)
72+
}
73+
}()
74+
75+
// Sync loop
76+
ctx, cancel := context.WithCancel(context.Background())
77+
defer cancel()
78+
79+
ticker := time.NewTicker(cfg.Sync.Interval)
80+
defer ticker.Stop()
81+
82+
// Run initial sync
83+
runSync(ctx, engine)
84+
85+
sigChan := make(chan os.Signal, 1)
86+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
87+
88+
for {
89+
select {
90+
case <-ticker.C:
91+
runSync(ctx, engine)
92+
case sig := <-sigChan:
93+
log.Printf("Received signal %v, shutting down...", sig)
94+
return
95+
case <-ctx.Done():
96+
return
97+
}
98+
}
99+
}
100+
101+
func runSync(ctx context.Context, engine *sync.Engine) {
102+
log.Println("Starting sync run...")
103+
if err := engine.Sync(ctx); err != nil {
104+
log.Printf("Sync error: %v", err)
105+
}
106+
log.Println("Sync run complete.")
107+
}

0 commit comments

Comments
 (0)