Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Image configuration
REGISTRY ?= ghcr.io/kelos-dev
VERSION ?= latest
IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/ghproxy cmd/kelos-webhook-server claude-code codex gemini opencode cursor
IMAGE_DIRS ?= cmd/kelos-controller cmd/kelos-spawner cmd/ghproxy cmd/kelos-webhook-server claude-code codex gemini opencode cursor cmd/kelos-slack-server
LOCAL_ARCH ?= $(shell go env GOARCH)

# Version injection for the kelos CLI – only set ldflags when an explicit
Expand Down
29 changes: 29 additions & 0 deletions api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ type When struct {
// the HMAC secret is read from the <SOURCE>_WEBHOOK_SECRET env var.
// +optional
GenericWebhook *GenericWebhook `json:"webhook,omitempty"`

// Slack discovers work items from Slack messages via Socket Mode.
// The centralized kelos-slack-server connects to Slack via an outbound
// WebSocket (no ingress required) and routes messages to matching agents.
// +optional
Slack *Slack `json:"slack,omitempty"`
}

// Cron triggers task spawning on a cron schedule.
Expand Down Expand Up @@ -483,6 +489,29 @@ type GenericWebhookFilter struct {
Pattern string `json:"pattern,omitempty"`
}

// Slack triggers task spawning from Slack messages via the centralized
// kelos-slack-server. The server connects to Slack via Socket Mode (outbound
// WebSocket — no ingress required) and routes messages to matching
// TaskSpawners. Authentication tokens (SLACK_BOT_TOKEN, SLACK_APP_TOKEN)
// are configured on the server, not per-TaskSpawner.
//
// The bot must be invited to each channel it should listen in.
type Slack struct {
// Channels optionally restricts which Slack channels the bot listens in.
// Values are channel IDs (e.g., "C0123456789"). When empty, the bot
// listens in every channel it has been invited to.
// +optional
Channels []string `json:"channels,omitempty"`

// MentionUserIDs optionally requires that the message @-mentions at least
// one of the specified Slack user IDs (e.g., "U0123456789"). In Slack,
// mentions appear as <@USER_ID> or <@USER_ID|display-name> in the message
// text. When empty, no mention is required. This filter is bypassed for
// slash commands but still required for thread replies.
// +optional
MentionUserIDs []string `json:"mentionUserIDs,omitempty"`
}

// TaskTemplateMetadata holds optional labels and annotations for spawned Tasks.
type TaskTemplateMetadata struct {
// Labels are merged into the spawned Task's labels. Values support Go
Expand Down
30 changes: 30 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions cmd/kelos-slack-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM gcr.io/distroless/static:nonroot
Comment thread
jkahuja marked this conversation as resolved.
WORKDIR /
COPY bin/kelos-slack-server .
USER 65532:65532
ENTRYPOINT ["/kelos-slack-server"]
126 changes: 126 additions & 0 deletions cmd/kelos-slack-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package main

import (
"context"
"flag"
"fmt"
"os"

"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"

kelosv1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1"
"github.com/kelos-dev/kelos/internal/logging"
kelosslack "github.com/kelos-dev/kelos/internal/slack"
)

var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
)

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(kelosv1alpha1.AddToScheme(scheme))
}

func main() {
var (
metricsAddr string
probeAddr string
enableLeaderElection bool
)

flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.")

opts, applyVerbosity := logging.SetupZapOptions(flag.CommandLine)
flag.Parse()

if err := applyVerbosity(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

ctrl.SetLogger(zap.New(zap.UseFlagOptions(opts)))

botToken := os.Getenv("SLACK_BOT_TOKEN")
appToken := os.Getenv("SLACK_APP_TOKEN")
if botToken == "" || appToken == "" {
setupLog.Error(fmt.Errorf("missing tokens"), "SLACK_BOT_TOKEN and SLACK_APP_TOKEN environment variables are required")
os.Exit(1)
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "kelos-slack-server",
})
if err != nil {
setupLog.Error(err, "Unable to start manager")
os.Exit(1)
}

ctx := ctrl.SetupSignalHandler()

// Create the Slack handler
handler, err := kelosslack.NewSlackHandler(
ctx,
mgr.GetClient(),
botToken,
appToken,
ctrl.Log.WithName("slack"),
)
if err != nil {
setupLog.Error(err, "Unable to create Slack handler")
os.Exit(1)
}

// Register Socket Mode listener as a leader-elected runnable so that only
// one replica opens the single-connection Socket Mode WebSocket.
if err := mgr.Add(&slackRunnable{handler: handler}); err != nil {
setupLog.Error(err, "Unable to register Slack handler with manager")
os.Exit(1)
}

// Health checks
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "Unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "Unable to set up ready check")
os.Exit(1)
}

setupLog.Info("Starting manager")
if err := mgr.Start(ctx); err != nil {
setupLog.Error(err, "Problem running manager")
os.Exit(1)
}
}

// slackRunnable wraps the SlackHandler as a leader-elected manager.Runnable.
// This ensures only the leader replica opens the Socket Mode connection.
type slackRunnable struct {
handler *kelosslack.SlackHandler
}

func (r *slackRunnable) Start(ctx context.Context) error {
setupLog.Info("Starting Slack Socket Mode listener")
err := r.handler.Start(ctx)
if err != nil && ctx.Err() == nil {
return err
}
return nil
}

func (r *slackRunnable) NeedLeaderElection() bool { return true }
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/robfig/cron/v3 v3.0.1
github.com/slack-go/slack v0.20.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.0
Expand Down Expand Up @@ -62,6 +63,7 @@ require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/renameio/v2 v2.0.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
Expand Down Expand Up @@ -111,6 +113,8 @@ 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/google/yamlfmt v0.21.0 h1:9FKApQkDpMKgBjwLFytBHUCgqnQgxaQnci0uiESfbzs=
github.com/google/yamlfmt v0.21.0/go.mod h1:q6FYExB+Ueu7jZDjKECJk+EaeDXJzJ6Ne0dxx69GWfI=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
Expand Down Expand Up @@ -195,6 +199,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/slack-go/slack v0.20.0 h1:gbDdbee8+Z2o+DWx05Spq3GzbrLLleiRwHUKs+hZLSU=
github.com/slack-go/slack v0.20.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
Expand Down
4 changes: 3 additions & 1 deletion internal/controller/taskspawner_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ func isCronBased(ts *kelosv1alpha1.TaskSpawner) bool {
}

// isWebhookBased returns true if the TaskSpawner is webhook-driven.
// Slack uses Socket Mode (outbound WebSocket) handled by the centralized
// kelos-slack-server, so it follows the same no-deployment pattern.
func isWebhookBased(ts *kelosv1alpha1.TaskSpawner) bool {
return ts.Spec.When.GitHubWebhook != nil || ts.Spec.When.LinearWebhook != nil || ts.Spec.When.GenericWebhook != nil
return ts.Spec.When.GitHubWebhook != nil || ts.Spec.When.LinearWebhook != nil || ts.Spec.When.GenericWebhook != nil || ts.Spec.When.Slack != nil
}

// Reconcile handles TaskSpawner reconciliation.
Expand Down
13 changes: 13 additions & 0 deletions internal/controller/taskspawner_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ func TestIsWebhookBased(t *testing.T) {
},
want: true,
},
{
name: "Slack TaskSpawner",
ts: &kelosv1alpha1.TaskSpawner{
Spec: kelosv1alpha1.TaskSpawnerSpec{
When: kelosv1alpha1.When{
Slack: &kelosv1alpha1.Slack{
Channels: []string{"C0123456789"},
},
},
},
},
want: true,
},
}

for _, tt := range tests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,31 @@ spec:
required:
- types
type: object
slack:
description: |-
Slack discovers work items from Slack messages via Socket Mode.
The centralized kelos-slack-server connects to Slack via an outbound
WebSocket (no ingress required) and routes messages to matching agents.
properties:
channels:
description: |-
Channels optionally restricts which Slack channels the bot listens in.
Values are channel IDs (e.g., "C0123456789"). When empty, the bot
listens in every channel it has been invited to.
items:
type: string
type: array
mentionUserIDs:
description: |-
MentionUserIDs optionally requires that the message @-mentions at least
one of the specified Slack user IDs (e.g., "U0123456789"). In Slack,
mentions appear as <@USER_ID> or <@USER_ID|display-name> in the message
text. When empty, no mention is required. This filter is bypassed for
slash commands but still required for thread replies.
items:
type: string
type: array
type: object
webhook:
description: |-
GenericWebhook triggers task spawning from arbitrary HTTP POST payloads.
Expand Down
Loading
Loading