mirror of
https://github.com/crowdsecurity/crowdsec.git
synced 2025-05-11 04:15:54 +02:00
253 lines
6.8 KiB
Go
253 lines
6.8 KiB
Go
package hubops
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/AlecAivazis/survey/v2"
|
|
|
|
"github.com/crowdsecurity/go-cs-lib/slicetools"
|
|
|
|
"github.com/crowdsecurity/crowdsec/pkg/cwhub"
|
|
)
|
|
|
|
// Command represents an operation that can be performed on a CrowdSec hub item.
|
|
//
|
|
// Each concrete implementation defines a Prepare() method to check for errors and preconditions,
|
|
// decide which sub-commands are required (like installing dependencies) and add them to the action plan.
|
|
type Command interface {
|
|
// Prepare sets up the command for execution within the given
|
|
// ActionPlan. It may add additional commands to the ActionPlan based
|
|
// on dependencies or prerequisites. Returns a boolean indicating
|
|
// whether the command execution should be skipped (it can be
|
|
// redundant, like installing something that is already installed) and
|
|
// an error if the preparation failed.
|
|
// NOTE: Returning an error will bubble up from the plan.AddCommand() method,
|
|
// but Prepare() might already have modified the plan's command slice.
|
|
Prepare(*ActionPlan) (bool, error)
|
|
|
|
// Run executes the command within the provided context and ActionPlan.
|
|
// It performs the actual operation and returns an error if execution fails.
|
|
// NOTE: Returning an error will currently stop the execution of the action plan.
|
|
Run(ctx context.Context, plan *ActionPlan) error
|
|
|
|
// OperationType returns a unique string representing the type of operation to perform
|
|
// (e.g., "download", "enable").
|
|
OperationType() string
|
|
|
|
// ItemType returns the type of item the operation is performed on
|
|
// (e.g., "collections"). Used in confirmation prompt and dry-run.
|
|
ItemType() string
|
|
|
|
// Detail provides further details on the operation,
|
|
// such as the item's name and version.
|
|
Detail() string
|
|
}
|
|
|
|
// UniqueKey generates a unique string key for a Command based on its operation type, item type, and detail.
|
|
// Is is used to avoid adding duplicate commands to the action plan.
|
|
func UniqueKey(c Command) string {
|
|
return fmt.Sprintf("%s:%s:%s", c.OperationType(), c.ItemType(), c.Detail())
|
|
}
|
|
|
|
// ActionPlan orchestrates the sequence of operations (Commands) to manage CrowdSec hub items.
|
|
type ActionPlan struct {
|
|
// hold the list of Commands to be executed as part of the action plan.
|
|
// If a command is skipped (i.e. calling Prepare() returned false), it won't be included in the slice.
|
|
commands []Command
|
|
|
|
// Tracks unique commands
|
|
commandsTracker map[string]struct{}
|
|
|
|
// A reference to the Hub instance, required for dependency lookup.
|
|
hub *cwhub.Hub
|
|
|
|
// Indicates whether a reload of the CrowdSec service is required after executing the action plan.
|
|
ReloadNeeded bool
|
|
}
|
|
|
|
func NewActionPlan(hub *cwhub.Hub) *ActionPlan {
|
|
return &ActionPlan{
|
|
hub: hub,
|
|
commandsTracker: make(map[string]struct{}),
|
|
}
|
|
}
|
|
|
|
func (p *ActionPlan) AddCommand(c Command) error {
|
|
ok, err := c.Prepare(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if ok {
|
|
key := UniqueKey(c)
|
|
if _, exists := p.commandsTracker[key]; !exists {
|
|
p.commands = append(p.commands, c)
|
|
p.commandsTracker[key] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Description returns a string representation of the action plan.
|
|
// If verbose is false, the operations are grouped by item type and operation type.
|
|
// If verbose is true, they are listed as they appear in the command slice.
|
|
func (p *ActionPlan) Description(verbose bool) string {
|
|
if verbose {
|
|
return p.verboseDescription()
|
|
}
|
|
|
|
return p.compactDescription()
|
|
}
|
|
|
|
func (p *ActionPlan) verboseDescription() string {
|
|
sb := strings.Builder{}
|
|
|
|
// Here we display the commands in the order they will be executed.
|
|
for _, cmd := range p.commands {
|
|
sb.WriteString(colorizeOpType(cmd.OperationType()) + " " + cmd.ItemType() + ":" + cmd.Detail() + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// describe the operations of a given type in a compact way.
|
|
func describe(opType string, desc map[string]map[string][]string, sb *strings.Builder) {
|
|
if _, ok := desc[opType]; !ok {
|
|
return
|
|
}
|
|
|
|
sb.WriteString(colorizeOpType(opType) + "\n")
|
|
|
|
// iterate cwhub.ItemTypes in reverse order, so we have collections first
|
|
for _, itemType := range slicetools.Backward(cwhub.ItemTypes) {
|
|
if desc[opType][itemType] == nil {
|
|
continue
|
|
}
|
|
|
|
details := desc[opType][itemType]
|
|
// Sorting for user convenience, but it's not the same order the commands will be carried out.
|
|
slices.Sort(details)
|
|
|
|
if itemType != "" {
|
|
sb.WriteString(" " + itemType + ": ")
|
|
}
|
|
|
|
if len(details) != 0 {
|
|
sb.WriteString(strings.Join(details, ", "))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *ActionPlan) compactDescription() string {
|
|
desc := make(map[string]map[string][]string)
|
|
|
|
for _, cmd := range p.commands {
|
|
opType := cmd.OperationType()
|
|
itemType := cmd.ItemType()
|
|
detail := cmd.Detail()
|
|
|
|
if _, ok := desc[opType]; !ok {
|
|
desc[opType] = make(map[string][]string)
|
|
}
|
|
|
|
desc[opType][itemType] = append(desc[opType][itemType], detail)
|
|
}
|
|
|
|
sb := strings.Builder{}
|
|
|
|
// Enforce presentation order.
|
|
|
|
describe("download", desc, &sb)
|
|
delete(desc, "download")
|
|
describe("enable", desc, &sb)
|
|
delete(desc, "enable")
|
|
describe("disable", desc, &sb)
|
|
delete(desc, "disable")
|
|
describe("remove", desc, &sb)
|
|
delete(desc, "remove")
|
|
|
|
for optype := range desc {
|
|
describe(optype, desc, &sb)
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (p *ActionPlan) Confirm(verbose bool) (bool, error) {
|
|
fmt.Println("The following actions will be performed:\n" + p.Description(verbose))
|
|
|
|
var answer bool
|
|
|
|
prompt := &survey.Confirm{
|
|
Message: "Do you want to continue?",
|
|
Default: true,
|
|
}
|
|
|
|
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
|
if err != nil {
|
|
return prompt.Default, nil
|
|
}
|
|
defer tty.Close()
|
|
|
|
// in case of EOF, it's likely stdin has been closed in a script or package manager,
|
|
// we can't do anything but go with the default
|
|
if err := survey.AskOne(prompt, &answer, survey.WithStdio(tty, tty, tty)); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return prompt.Default, nil
|
|
}
|
|
|
|
return false, err
|
|
}
|
|
|
|
fmt.Println()
|
|
|
|
return answer, nil
|
|
}
|
|
|
|
func (p *ActionPlan) Execute(ctx context.Context, interactive bool, dryRun bool, alwaysShowPlan bool, verbosePlan bool) error {
|
|
// interactive: show action plan, ask for confirm
|
|
// dry-run: show action plan, no prompt, no action
|
|
// alwaysShowPlan: print plan even if interactive and dry-run are false
|
|
// verbosePlan: plan summary is displaying each step in order
|
|
if len(p.commands) == 0 {
|
|
fmt.Println("Nothing to do.")
|
|
return nil
|
|
}
|
|
|
|
if interactive {
|
|
answer, err := p.Confirm(verbosePlan)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !answer {
|
|
fmt.Println("Operation canceled.")
|
|
return nil
|
|
}
|
|
} else {
|
|
if dryRun || alwaysShowPlan {
|
|
fmt.Println("Action plan:\n" + p.Description(verbosePlan))
|
|
}
|
|
|
|
if dryRun {
|
|
fmt.Println("Dry run, no action taken.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
for _, c := range p.commands {
|
|
if err := c.Run(ctx, p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|