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
3 changes: 3 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ func main() {

setConfig(&cfg)

rootCmd.AddCommand(newRollCommand(log))

if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
Expand All @@ -101,6 +103,7 @@ func setConfig(cfg *service.Config) {
cfg.S3EndpointURL = os.Getenv("AWS_ENDPOINT_URL")
cfg.HealthCheckAddress = os.Getenv("HEALTH_CHECK_ADDRESS")
cfg.MetricsAddress = os.Getenv("METRICS_ADDRESS")
cfg.WatchtowerAPIToken = os.Getenv("WATCHTOWER_HTTP_API_TOKEN")

if cfg.GrafanaBaseURL == "" {
cfg.GrafanaBaseURL = grafana.DefaultGrafanaBaseURL
Expand Down
93 changes: 93 additions & 0 deletions cmd/roll.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"

"github.com/ethpandaops/panda-pulse/pkg/roll"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

// newRollCommand builds the `panda-pulse roll` subcommand: a gated, sequential
// image rollout across a network's nodes, resolved from cartographoor inventory,
// health-gated on Dora, and triggered via each node's watchtower vhost.
func newRollCommand(log *logrus.Logger) *cobra.Command {
var (
network string
client string
image string
inventoryURL string
doraURL string
watchtowerToken string
skipHealth bool
dryRun bool
delay time.Duration
postTrigger time.Duration
waitTimeout time.Duration
healthInterval time.Duration
)

cmd := &cobra.Command{
Use: "roll",
Short: "Gated rolling image update across a network's nodes",
SilenceUsage: true,
RunE: func(_ *cobra.Command, _ []string) error {
if network == "" {
return fmt.Errorf("--network is required")
}

if watchtowerToken == "" {
return fmt.Errorf("--watchtower-token (or WATCHTOWER_HTTP_API_TOKEN) is required")
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

inv, err := roll.FetchInventory(ctx, inventoryURL, network)
if err != nil {
return err
}

targets := roll.Select(roll.ResolveTargets(inv), client)
if len(targets) == 0 {
return fmt.Errorf("no targets matched (network=%s client=%q)", network, client)
}

if doraURL == "" {
doraURL = roll.DoraURLForNetwork(network)
}

return roll.NewEngine(roll.NewAPIActuator(watchtowerToken, network), log).Run(ctx, targets, roll.Options{
Image: image,
DelayBetweenNodes: delay,
PostTriggerWait: postTrigger,
WaitTimeout: waitTimeout,
HealthCheckInterval: healthInterval,
SkipHealth: skipHealth,
DryRun: dryRun,
DoraURL: doraURL,
})
},
}

f := cmd.Flags()
f.StringVar(&network, "network", "", "network name as published by cartographoor (required)")
f.StringVar(&client, "client", "", "host pattern: client/group/node with globs, ! to exclude, 'all' (e.g. 'lighthouse', 'lighthouse_ethrex', 'lighthouse-*:!*-1')")
f.StringVar(&image, "image", "", "scope the roll to this image (empty = all watched containers)")
f.StringVar(&inventoryURL, "inventory-url", roll.DefaultInventoryBaseURL, "cartographoor inventory base URL")
f.StringVar(&doraURL, "dora-url", "", "Dora health source URL (default: https://dora.<network>.ethpandaops.io)")
f.StringVar(&watchtowerToken, "watchtower-token", os.Getenv("WATCHTOWER_HTTP_API_TOKEN"), "watchtower API token (env WATCHTOWER_HTTP_API_TOKEN)")
f.BoolVar(&skipHealth, "skip-health", false, "skip health gating (force; trigger-and-go even if unhealthy)")
f.BoolVar(&dryRun, "dry-run", false, "log intent without triggering rolls")
f.DurationVar(&delay, "delay-roll", time.Minute, "wait between hosts (~N minutes for N hosts); overridable")
f.DurationVar(&postTrigger, "post-trigger-wait", 30*time.Second, "grace period after triggering before polling recovery")
f.DurationVar(&waitTimeout, "wait-timeout", 10*time.Minute, "per-node recovery timeout before aborting")
f.DurationVar(&healthInterval, "health-check-interval", 10*time.Second, "Dora health poll cadence during recovery")

return cmd
}
2 changes: 1 addition & 1 deletion pkg/discord/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ func (b *DiscordBot) scheduleDiscordChoiceRefresh() error {
// and /hive trigger has bespoke per-subcommand handling.
func commandSelfChecksPermission(cmdName string, data *discordgo.ApplicationCommandInteractionData) bool {
switch cmdName {
case "build":
case "build", "roll":
return true
case "hive":
return data != nil && len(data.Options) > 0 && data.Options[0].Name == "trigger"
Expand Down
247 changes: 247 additions & 0 deletions pkg/discord/cmd/roll/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
// Package roll provides the /roll Discord command: gated, sequential image
// rollouts across a network's nodes, resolved from cartographoor inventory and
// executed via the rollpkg engine.
package roll

import (
"context"
"fmt"
"strings"
"time"

"github.com/bwmarrin/discordgo"
"github.com/ethpandaops/panda-pulse/pkg/discord/cmd/common"
rollpkg "github.com/ethpandaops/panda-pulse/pkg/roll"
"github.com/sirupsen/logrus"
)

const (
optionNetwork = "network"
optionClient = "client"
optionImage = "image"
optionDelay = "delay"
optionForce = "force"
optionDryRun = "dry_run"

autocompleteLimit = 25
providerCacheTTL = 30 * time.Second
autocompleteTime = 2 * time.Second
)

// Config configures the roll command's actuator and inventory source.
type Config struct {
// WatchtowerToken is the bearer token for the watchtower HTTP API.
WatchtowerToken string
// InventoryURL overrides the cartographoor inventory base URL.
InventoryURL string
// DoraURL overrides the Dora health source; empty derives it from the
// network (https://dora.<network>.ethpandaops.io).
DoraURL string
}

// Command implements the /roll Discord slash command.
type Command struct {
log *logrus.Logger
bot common.BotContext
cfg Config
provider *rollpkg.InventoryProvider
guildRegistrations map[string]string
}

// NewRollCommand creates the /roll command.
func NewRollCommand(log *logrus.Logger, bot common.BotContext, cfg Config) *Command {
return &Command{
log: log,
bot: bot,
cfg: cfg,
provider: rollpkg.NewInventoryProvider(cfg.InventoryURL, providerCacheTTL),
}
}

// Name returns the command name.
func (c *Command) Name() string { return "roll" }

func (c *Command) getCommandDefinition() *discordgo.ApplicationCommand {
return &discordgo.ApplicationCommand{
Name: c.Name(),
Description: "Roll (re-pull + restart) client images across a network — gated and sequential",
Options: []*discordgo.ApplicationCommandOption{
{
Name: optionNetwork,
Description: "Network name (e.g. glamsterdam-devnet-4)",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
{
Name: optionClient,
Description: "Host pattern: client/group/node, globs, ! to exclude, 'all'",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
Autocomplete: true,
},
{
Name: optionImage,
Description: "Scope to a specific image (optional, e.g. ethpandaops/lighthouse)",
Type: discordgo.ApplicationCommandOptionString,
Required: false,
},
{
Name: optionDelay,
Description: "Delay between hosts, e.g. 1m or 90s (default 1m)",
Type: discordgo.ApplicationCommandOptionString,
Required: false,
},
{
Name: optionForce,
Description: "Skip health checks — force the roll even if the node is unhealthy (e.g. known-bad node)",
Type: discordgo.ApplicationCommandOptionBoolean,
Required: false,
},
{
Name: optionDryRun,
Description: "List what would roll without triggering",
Type: discordgo.ApplicationCommandOptionBoolean,
Required: false,
},
},
}
}

// Register registers the command globally.
func (c *Command) Register(session *discordgo.Session) error {
cmd, err := session.ApplicationCommandCreate(session.State.User.ID, "", c.getCommandDefinition())
if err != nil {
return fmt.Errorf("failed to register roll command: %w", err)
}

if c.guildRegistrations == nil {
c.guildRegistrations = make(map[string]string, 1)
}

c.guildRegistrations[""] = cmd.ID

return nil
}

// RegisterWithGuild registers the command to a specific guild.
func (c *Command) RegisterWithGuild(session *discordgo.Session, guildID string) error {
cmd, err := session.ApplicationCommandCreate(session.State.User.ID, guildID, c.getCommandDefinition())
if err != nil {
return fmt.Errorf("failed to register roll command to guild %s: %w", guildID, err)
}

if c.guildRegistrations == nil {
c.guildRegistrations = make(map[string]string, 2)
}

c.guildRegistrations[guildID] = cmd.ID

c.log.WithField("guild", guildID).Info("Registered roll command to guild")

return nil
}

// Handle dispatches autocomplete and command interactions.
func (c *Command) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type {
case discordgo.InteractionApplicationCommandAutocomplete:
c.handleAutocomplete(s, i)
case discordgo.InteractionApplicationCommand:
c.handleCommand(s, i)
}
}

func (c *Command) handleCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.ApplicationCommandData()
if data.Name != c.Name() {
return
}

if !c.hasPermission(i.Member, s, i.GuildID) {
c.respondEphemeral(s, i, common.NoPermissionError(c.Name()).Error())

return
}

if err := c.run(s, i, data); err != nil {
c.log.WithError(err).Error("roll command failed")
}
}

// hasPermission allows admins or any team member to roll (mirrors /build).
func (c *Command) hasPermission(member *discordgo.Member, session *discordgo.Session, guildID string) bool {
cfg := c.bot.GetRoleConfig()

for _, roleName := range common.GetRoleNames(member, session, guildID) {
if cfg.AdminRoles[strings.ToLower(roleName)] {
return true
}

for _, teamRoles := range cfg.ClientRoles {
for _, teamRole := range teamRoles {
if strings.EqualFold(teamRole, roleName) {
return true
}
}
}
}

return false
}

// handleAutocomplete serves dynamic suggestions for the client option, sourced
// from the selected network's inventory (groups, node names, and "all").
func (c *Command) handleAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.ApplicationCommandData()

var network, input string

focused := false

for _, opt := range data.Options {
switch opt.Name {
case optionNetwork:
network = opt.StringValue()
case optionClient:
if opt.Focused {
focused = true
input = opt.StringValue()
}
}
}

var choices []*discordgo.ApplicationCommandOptionChoice

if focused && network != "" {
ctx, cancel := context.WithTimeout(context.Background(), autocompleteTime)
defer cancel()

if sugg, err := c.provider.Suggest(ctx, network, input, "", autocompleteLimit); err != nil {
c.log.WithError(err).Debug("roll autocomplete: suggest failed")
} else {
choices = make([]*discordgo.ApplicationCommandOptionChoice, 0, len(sugg))
for _, tok := range sugg {
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: tok, Value: tok})
}
}
}

if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
Data: &discordgo.InteractionResponseData{Choices: choices},
}); err != nil {
c.log.WithError(err).Debug("Failed to respond to roll autocomplete")
}
}

func (c *Command) respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: content,
Flags: discordgo.MessageFlagsEphemeral,
},
}); err != nil {
c.log.WithError(err).Error("Failed to send ephemeral response")
}
}
Loading
Loading