Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
25 changes: 24 additions & 1 deletion api/v1alpha2/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,11 @@ type GenericWebhookFilter struct {
// are configured on the server, not per-TaskSpawner.
//
// The bot must be invited to each channel it should listen in; the Channels
// field is a post-delivery filter, not a privacy scope.
// and ExcludeChannels fields are post-delivery filters, not a privacy scope.
// The server has already received the message — and, for a thread reply, has
// already fetched the thread history — before either field is consulted, and
// the bot stays in an excluded channel and still greets it on join. Remove the
// bot from a channel to stop delivery itself.
//
// Bot mention (@bot) is implicitly required by default. The handler knows its
// own bot user ID from the Slack auth response. When Triggers are configured,
Expand All @@ -665,6 +669,25 @@ type Slack struct {
// +kubebuilder:validation:items:Pattern=`^[CG][A-Z0-9]{8,}$`
Channels []string `json:"channels,omitempty"`

// ExcludeChannels rejects Slack events from the given channels regardless
// of the Channels allowlist — an excluded channel is never matched, even
// when Channels is empty (all channels) or names the same channel.
// Unlike ExcludePatterns, this also applies to slash commands.
//
// Values are channel IDs. Direct-message IDs ("D0123456789") are accepted
// here even though Channels does not accept them, so a spawner that
// listens in every channel can still be kept out of DMs.
//
// The exclusion is only guaranteed while the object is managed through
// v1alpha2. This field does not exist in v1alpha1; it survives a v1alpha1
// round-trip through a preservation annotation, so a v1alpha1 client that
// drops unknown annotations drops the exclusion with them.
// +optional
// +listType=set
// +kubebuilder:validation:MaxItems=64
// +kubebuilder:validation:items:Pattern=`^[CGD][A-Z0-9]{8,}$`
ExcludeChannels []string `json:"excludeChannels,omitempty"`

// BotMessages controls whether bot-originated messages can trigger this
// spawner. Accepting bot messages carries loop risk — especially "All"
// which includes the bot's own output. Use ExcludePatterns or Triggers
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha2/zz_generated.deepcopy.go

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

1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ to receive refreshed credentials during long-running work.
| `spec.when.linearWebhook.filters[].excludeLabels` | Exclude issues with any of these labels | No |
| `spec.when.linearWebhook.gatewayRef.name` | Bind this source to a [WebhookGateway](#webhookgateway) in the same namespace whose `spec.linear` field is set. The per-source webhook server ignores this spawner when the reference is present | No |
| `spec.when.slack.channels` | Restrict which Slack channels the bot listens in (channel IDs like `"C0123456789"`); when empty, listens in all invited channels | No |
| `spec.when.slack.excludeChannels` | Channel IDs this spawner never matches; exclusion always wins, so a channel listed here is rejected even when `channels` is empty (all channels) or names the same channel. Unlike `excludePatterns`, it also applies to slash commands. Direct-message IDs (`"D0123456789"`) are accepted here even though `channels` does not accept them. Filters after delivery, like `channels` — the bot stays in the channel. Stored only in `v1alpha2`; a client that writes the spawner through `v1alpha1` preserves the exclusion in an annotation, so stripping that annotation drops it | No |
| `spec.when.slack.botMessagePolicy` | Controls whether bot-originated messages can trigger this spawner: `None` (default) rejects all bot messages, `All` allows all including self, `OthersOnly` allows other bots but rejects the bot's own output to prevent self-trigger loops | No |
| `spec.when.slack.triggers[].pattern` | RE2 regex matched against message text (unanchored); leading `<@USER_ID>` mentions are stripped before matching; bot mention required unless `mentionOptional` is set; multiple triggers use OR semantics; when empty, every bot mention fires | No |
| `spec.when.slack.triggers[].mentionOptional` | When `true`, fire on pattern match alone without requiring a bot @-mention | No |
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ func printTaskSpawnerDetail(w io.Writer, ts *kelos.TaskSpawner) {
if len(sl.Channels) > 0 {
printField(w, "Channels", fmt.Sprintf("%v", sl.Channels))
}
if len(sl.ExcludeChannels) > 0 {
printField(w, "Exclude Channels", fmt.Sprintf("%v", sl.ExcludeChannels))
}
if len(sl.Triggers) > 0 {
patterns := make([]string, len(sl.Triggers))
for i, tr := range sl.Triggers {
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/printer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ func TestPrintTaskSpawnerDetailSlack(t *testing.T) {
Spec: kelos.TaskSpawnerSpec{
When: kelos.When{
Slack: &kelos.Slack{
Channels: []string{"C0123456789", "C9876543210"},
Channels: []string{"C0123456789", "C9876543210"},
ExcludeChannels: []string{"C1122334455"},
Triggers: []kelos.SlackTrigger{
{Pattern: "deploy"},
{Pattern: "rollback"},
Expand All @@ -473,6 +474,7 @@ func TestPrintTaskSpawnerDetailSlack(t *testing.T) {
for _, expected := range []string{
"Source: Slack",
"Channels: [C0123456789 C9876543210]",
"Exclude Channels: [C1122334455]",
"Triggers: [deploy rollback]",
"Exclude Patterns: [^ignore]",
} {
Expand Down
86 changes: 86 additions & 0 deletions internal/conversion/taskspawner.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package conversion
import (
"context"
"encoding/json"
"regexp"

v1alpha1 "github.com/kelos-dev/kelos/api/v1alpha1"
v1alpha2 "github.com/kelos-dev/kelos/api/v1alpha2"
Expand Down Expand Up @@ -42,6 +43,21 @@ type preservedWebhookGatewayRefs struct {
Generic *v1alpha2.GatewayReference `json:"generic,omitempty"`
}

// preservedSlackExcludeChannelsAnnotation carries spec.when.slack.excludeChannels
// (a v1alpha2-only field) across a v1alpha1 round-trip so a client that reads
// and writes the object through v1alpha1 does not silently drop it. v1alpha1
// does not gain the capability — the value only survives in this annotation.
const preservedSlackExcludeChannelsAnnotation = "kelos.dev/v1alpha2-slack-exclude-channels"

// slackExcludeChannelsMaxItems and slackExcludeChannelIDPattern mirror the
// validation markers on v1alpha2 Slack.ExcludeChannels. The API server does not
// re-validate the output of a conversion webhook, so annotation data — which any
// v1alpha1 client can write by hand — would otherwise reach the hub object
// having bypassed the field's own constraints.
const slackExcludeChannelsMaxItems = 64

var slackExcludeChannelIDPattern = regexp.MustCompile(`^[CGD][A-Z0-9]{8,}$`)

type preservedGitHubCommentsReporting struct {
GitHubIssues *preservedGitHubCommentsSource `json:"githubIssues,omitempty"`
GitHubPullRequests *preservedGitHubCommentsSource `json:"githubPullRequests,omitempty"`
Expand Down Expand Up @@ -76,6 +92,8 @@ func taskSpawnerToHub(_ context.Context, src *v1alpha1.TaskSpawner, dst *v1alpha
deleteAnnotation(dst.Annotations, preservedGitHubCommentsReportingAnnotation)
restorePreservedWebhookGatewayRefs(src.Annotations, &dst.Spec.When)
deleteAnnotation(dst.Annotations, preservedWebhookGatewayRefsAnnotation)
restorePreservedSlackExcludeChannels(src.Annotations, dst.Spec.When.Slack)
deleteAnnotation(dst.Annotations, preservedSlackExcludeChannelsAnnotation)
return nil
}

Expand All @@ -101,6 +119,9 @@ func taskSpawnerFromHub(_ context.Context, src *v1alpha2.TaskSpawner, dst *v1alp
if err := setPreservedWebhookGatewayRefs(dst, src.Spec.When); err != nil {
return err
}
if err := setPreservedSlackExcludeChannels(dst, src.Spec.When.Slack); err != nil {
return err
}
return convertViaJSON(&src.Status, &dst.Status)
}

Expand Down Expand Up @@ -170,6 +191,71 @@ func restorePreservedNameTemplate(annotations map[string]string, dst *v1alpha2.T
}
}

// setPreservedSlackExcludeChannels records spec.when.slack.excludeChannels in
// an annotation on the v1alpha1 object so the field survives a v1alpha1
// round-trip. The annotation is cleared when there is nothing to preserve.
func setPreservedSlackExcludeChannels(dst *v1alpha1.TaskSpawner, slack *v1alpha2.Slack) error {
if slack == nil || len(slack.ExcludeChannels) == 0 {
deleteAnnotation(dst.Annotations, preservedSlackExcludeChannelsAnnotation)
return nil
}
data, err := json.Marshal(slack.ExcludeChannels)
if err != nil {
return err
}
if dst.Annotations == nil {
dst.Annotations = map[string]string{}
}
dst.Annotations[preservedSlackExcludeChannelsAnnotation] = string(data)
return nil
}

// restorePreservedSlackExcludeChannels restores excludeChannels dropped by a
// v1alpha1 round-trip, unless the v1alpha2 object already carries the field.
func restorePreservedSlackExcludeChannels(annotations map[string]string, slack *v1alpha2.Slack) {
if slack == nil || len(slack.ExcludeChannels) > 0 {
return
}
raw, ok := annotations[preservedSlackExcludeChannelsAnnotation]
if !ok || raw == "" {
return
}
var excludeChannels []string
if err := json.Unmarshal([]byte(raw), &excludeChannels); err != nil || len(excludeChannels) == 0 {
Comment thread
knechtionscoding marked this conversation as resolved.
// The annotation is best-effort preservation data and can be set by
// users; malformed data must not block API version conversion.
return
}
if !validSlackExcludeChannels(excludeChannels) {
return
}
slack.ExcludeChannels = excludeChannels
Comment thread
knechtionscoding marked this conversation as resolved.
}

// validSlackExcludeChannels reports whether restored annotation data satisfies
// the constraints declared on v1alpha2 Slack.ExcludeChannels: at most
// slackExcludeChannelsMaxItems entries, each a well-formed channel ID, no
// duplicates (the field is a set). Data that fails any of these is treated the
// same as malformed JSON — ignored entirely, rather than partially applied, so
// conversion can never produce a hub object that a v1alpha2 write would have
// rejected.
func validSlackExcludeChannels(excludeChannels []string) bool {
if len(excludeChannels) > slackExcludeChannelsMaxItems {
return false
}
seen := make(map[string]struct{}, len(excludeChannels))
for _, id := range excludeChannels {
if !slackExcludeChannelIDPattern.MatchString(id) {
return false
}
if _, dup := seen[id]; dup {
return false
}
seen[id] = struct{}{}
}
return true
}

// setPreservedContextGitHubAppAuth records the githubAppAuth block of each
// context source (keyed by source name) into an annotation on the v1alpha1
// object so it survives a v1alpha1 round-trip. The annotation is cleared when
Expand Down
Loading
Loading