From 9839354a9db9903915d84fc6688fe861f9c9d21c Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 3 Jul 2024 16:50:25 +0200 Subject: [PATCH 1/2] command cscli [machines|bouncers] inspect --- .golangci.yml | 2 + cmd/crowdsec-cli/bouncers.go | 217 ++++++++++++++++---- cmd/crowdsec-cli/bouncers_table.go | 33 ---- cmd/crowdsec-cli/machines.go | 306 +++++++++++++++++++++++++---- cmd/crowdsec-cli/machines_table.go | 33 ---- cmd/crowdsec-cli/support.go | 22 +-- pkg/database/ent/helpers.go | 50 +++++ pkg/database/ent/schema/machine.go | 1 + test/bats/30_machines.bats | 7 +- 9 files changed, 522 insertions(+), 149 deletions(-) delete mode 100644 cmd/crowdsec-cli/bouncers_table.go delete mode 100644 cmd/crowdsec-cli/machines_table.go create mode 100644 pkg/database/ent/helpers.go diff --git a/.golangci.yml b/.golangci.yml index 66c720381de..855c73f9af3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -143,6 +143,8 @@ linters-settings: disabled: true - name: struct-tag disabled: true + - name: redundant-import-alias + disabled: true - name: time-equal disabled: true - name: var-naming diff --git a/cmd/crowdsec-cli/bouncers.go b/cmd/crowdsec-cli/bouncers.go index 3da9575146e..0673473d72a 100644 --- a/cmd/crowdsec-cli/bouncers.go +++ b/cmd/crowdsec-cli/bouncers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "slices" "strings" @@ -12,12 +13,16 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require" middlewares "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1" "github.com/crowdsecurity/crowdsec/pkg/database" + "github.com/crowdsecurity/crowdsec/pkg/database/ent" + "github.com/crowdsecurity/crowdsec/pkg/database/ent/bouncer" + "github.com/crowdsecurity/crowdsec/pkg/emoji" "github.com/crowdsecurity/crowdsec/pkg/types" ) @@ -79,13 +84,92 @@ Note: This command requires database direct access, so is intended to be run on cmd.AddCommand(cli.newAddCmd()) cmd.AddCommand(cli.newDeleteCmd()) cmd.AddCommand(cli.newPruneCmd()) + cmd.AddCommand(cli.newInspectCmd()) return cmd } -func (cli *cliBouncers) list() error { - out := color.Output +func (cli *cliBouncers) listHuman(out io.Writer, bouncers ent.Bouncers) { + t := newLightTable(out).Writer + t.AppendHeader(table.Row{"Name", "IP Address", "Valid", "Last API pull", "Type", "Version", "Auth Type"}) + + for _, b := range bouncers { + revoked := emoji.CheckMark + if b.Revoked { + revoked = emoji.Prohibited + } + + lastPull := "" + if b.LastPull != nil { + lastPull = b.LastPull.Format(time.RFC3339) + } + + t.AppendRow(table.Row{b.Name, b.IPAddress, revoked, lastPull, b.Type, b.Version, b.AuthType}) + } + + fmt.Fprintln(out, t.Render()) +} + +// bouncerInfo contains only the data we want for inspect/list +type bouncerInfo struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Revoked bool `json:"revoked"` + IPAddress string `json:"ip_address"` + Type string `json:"type"` + Version string `json:"version"` + LastPull *time.Time `json:"last_pull"` + AuthType string `json:"auth_type"` + OS string `json:"os,omitempty"` + Featureflags []string `json:"featureflags,omitempty"` +} + +func newBouncerInfo(b *ent.Bouncer) bouncerInfo { + return bouncerInfo{ + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + Name: b.Name, + Revoked: b.Revoked, + IPAddress: b.IPAddress, + Type: b.Type, + Version: b.Version, + LastPull: b.LastPull, + AuthType: b.AuthType, + OS: b.GetOSNameAndVersion(), + Featureflags: b.GetFeatureFlagList(), + } +} + +func (cli *cliBouncers) listCSV(out io.Writer, bouncers ent.Bouncers) error { + csvwriter := csv.NewWriter(out) + + if err := csvwriter.Write([]string{"name", "ip", "revoked", "last_pull", "type", "version", "auth_type"}); err != nil { + return fmt.Errorf("failed to write raw header: %w", err) + } + + for _, b := range bouncers { + valid := "validated" + if b.Revoked { + valid = "pending" + } + + lastPull := "" + if b.LastPull != nil { + lastPull = b.LastPull.Format(time.RFC3339) + } + if err := csvwriter.Write([]string{b.Name, b.IPAddress, valid, lastPull, b.Type, b.Version, b.AuthType}); err != nil { + return fmt.Errorf("failed to write raw: %w", err) + } + } + + csvwriter.Flush() + return nil +} + + +func (cli *cliBouncers) list(out io.Writer) error { bouncers, err := cli.db.ListBouncers() if err != nil { return fmt.Errorf("unable to list bouncers: %w", err) @@ -93,40 +177,23 @@ func (cli *cliBouncers) list() error { switch cli.cfg().Cscli.Output { case "human": - getBouncersTable(out, bouncers) + cli.listHuman(out, bouncers) case "json": + info := make([]bouncerInfo, 0, len(bouncers)) + for _, b := range bouncers { + info = append(info, newBouncerInfo(b)) + } + enc := json.NewEncoder(out) enc.SetIndent("", " ") - if err := enc.Encode(bouncers); err != nil { - return fmt.Errorf("failed to marshal: %w", err) + if err := enc.Encode(info); err != nil { + return errors.New("failed to marshal") } return nil case "raw": - csvwriter := csv.NewWriter(out) - - if err := csvwriter.Write([]string{"name", "ip", "revoked", "last_pull", "type", "version", "auth_type"}); err != nil { - return fmt.Errorf("failed to write raw header: %w", err) - } - - for _, b := range bouncers { - valid := "validated" - if b.Revoked { - valid = "pending" - } - - lastPull := "" - if b.LastPull != nil { - lastPull = b.LastPull.Format(time.RFC3339) - } - - if err := csvwriter.Write([]string{b.Name, b.IPAddress, valid, lastPull, b.Type, b.Version, b.AuthType}); err != nil { - return fmt.Errorf("failed to write raw: %w", err) - } - } - - csvwriter.Flush() + return cli.listCSV(out, bouncers) } return nil @@ -140,7 +207,7 @@ func (cli *cliBouncers) newListCmd() *cobra.Command { Args: cobra.ExactArgs(0), DisableAutoGenTag: true, RunE: func(_ *cobra.Command, _ []string) error { - return cli.list() + return cli.list(color.Output) }, } @@ -206,13 +273,14 @@ cscli bouncers add MyBouncerName --key `, return cmd } -func (cli *cliBouncers) deleteValid(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // need to load config and db because PersistentPreRunE is not called for completions - +// validBouncerID returns a list of bouncer IDs for command completion +func (cli *cliBouncers) validBouncerID(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { var err error cfg := cli.cfg() + // need to load config and db because PersistentPreRunE is not called for completions + if err = require.LAPI(cfg); err != nil { cobra.CompError("unable to list bouncers " + err.Error()) return nil, cobra.ShellCompDirectiveNoFileComp @@ -261,7 +329,7 @@ func (cli *cliBouncers) newDeleteCmd() *cobra.Command { Args: cobra.MinimumNArgs(1), Aliases: []string{"remove"}, DisableAutoGenTag: true, - ValidArgsFunction: cli.deleteValid, + ValidArgsFunction: cli.validBouncerID, RunE: func(_ *cobra.Command, args []string) error { return cli.delete(args) }, @@ -292,7 +360,7 @@ func (cli *cliBouncers) prune(duration time.Duration, force bool) error { return nil } - getBouncersTable(color.Output, bouncers) + cli.listHuman(color.Output, bouncers) if !force { if yes, err := askYesNo( @@ -341,3 +409,84 @@ cscli bouncers prune -d 45m --force`, return cmd } + +func (cli *cliBouncers) inspectHuman(out io.Writer, bouncer *ent.Bouncer) { + t := newTable(out).Writer + + t.SetTitle("Bouncer: " + bouncer.Name) + + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + }) + + lastPull := "" + if bouncer.LastPull != nil { + lastPull = bouncer.LastPull.String() + } + + t.AppendRows([]table.Row{ + {"Created At", bouncer.CreatedAt}, + {"Last Update", bouncer.UpdatedAt}, + {"Revoked?", bouncer.Revoked}, + {"IP Address", bouncer.IPAddress}, + {"Type", bouncer.Type}, + {"Version", bouncer.Version}, + {"Last Pull", lastPull}, + {"Auth type", bouncer.AuthType}, + {"OS", bouncer.GetOSNameAndVersion()}, + }) + + for _, ff := range bouncer.GetFeatureFlagList() { + t.AppendRow(table.Row{"Feature Flags", ff}) + } + + fmt.Fprintln(out, t.Render()) +} + +func (cli *cliBouncers) inspect(bouncer *ent.Bouncer) error { + out := color.Output + outputFormat := cli.cfg().Cscli.Output + + switch outputFormat { + case "human": + cli.inspectHuman(out, bouncer) + case "json": + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + + if err := enc.Encode(newBouncerInfo(bouncer)); err != nil { + return errors.New("failed to marshal") + } + + return nil + default: + return fmt.Errorf("output format '%s' not supported for this command", outputFormat) + } + return nil +} + + +func (cli *cliBouncers) newInspectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect [bouncer_name]", + Short: "inspect a bouncer by name", + Example: `cscli bouncers inspect "bouncer1"`, + Args: cobra.ExactArgs(1), + DisableAutoGenTag: true, + ValidArgsFunction: cli.validBouncerID, + RunE: func(cmd *cobra.Command, args []string) error { + bouncerName := args[0] + + b, err := cli.db.Ent.Bouncer.Query(). + Where(bouncer.Name(bouncerName)). + Only(cmd.Context()) + if err != nil { + return fmt.Errorf("unable to read bouncer data '%s': %w", bouncerName, err) + } + + return cli.inspect(b) + }, + } + + return cmd +} diff --git a/cmd/crowdsec-cli/bouncers_table.go b/cmd/crowdsec-cli/bouncers_table.go deleted file mode 100644 index c32762ba266..00000000000 --- a/cmd/crowdsec-cli/bouncers_table.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "io" - "time" - - "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/table" - "github.com/crowdsecurity/crowdsec/pkg/database/ent" - "github.com/crowdsecurity/crowdsec/pkg/emoji" -) - -func getBouncersTable(out io.Writer, bouncers []*ent.Bouncer) { - t := newLightTable(out) - t.SetHeaders("Name", "IP Address", "Valid", "Last API pull", "Type", "Version", "Auth Type") - t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft) - t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft) - - for _, b := range bouncers { - revoked := emoji.CheckMark - if b.Revoked { - revoked = emoji.Prohibited - } - - lastPull := "" - if b.LastPull != nil { - lastPull = b.LastPull.Format(time.RFC3339) - } - - t.AddRow(b.Name, b.IPAddress, revoked, lastPull, b.Type, b.Version, b.AuthType) - } - - t.Render() -} diff --git a/cmd/crowdsec-cli/machines.go b/cmd/crowdsec-cli/machines.go index 746045d0eab..155d7679833 100644 --- a/cmd/crowdsec-cli/machines.go +++ b/cmd/crowdsec-cli/machines.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math/big" "os" "slices" @@ -16,17 +17,21 @@ import ( "github.com/fatih/color" "github.com/go-openapi/strfmt" "github.com/google/uuid" + "github.com/jedib0t/go-pretty/v6/table" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "gopkg.in/yaml.v3" "github.com/crowdsecurity/machineid" - "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require" "github.com/crowdsecurity/crowdsec/pkg/csconfig" + "github.com/crowdsecurity/crowdsec/pkg/cwhub" "github.com/crowdsecurity/crowdsec/pkg/database" "github.com/crowdsecurity/crowdsec/pkg/database/ent" + "github.com/crowdsecurity/crowdsec/pkg/emoji" "github.com/crowdsecurity/crowdsec/pkg/types" + + "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require" ) const passwordLength = 64 @@ -147,13 +152,126 @@ Note: This command requires database direct access, so is intended to be run on cmd.AddCommand(cli.newDeleteCmd()) cmd.AddCommand(cli.newValidateCmd()) cmd.AddCommand(cli.newPruneCmd()) + cmd.AddCommand(cli.newInspectCmd()) return cmd } -func (cli *cliMachines) list() error { - out := color.Output +func (*cliMachines) inspectHubHuman(out io.Writer, machine *ent.Machine) { + state := machine.Hubstate + + if len(state) == 0 { + fmt.Println("No hub items found for this machine") + return + } + + // group state rows by type for multiple tables + rowsByType := make(map[string][]table.Row) + + for itemType, items := range state { + for _, item := range items { + if _, ok := rowsByType[itemType]; !ok { + rowsByType[itemType] = make([]table.Row, 0) + } + + row := table.Row{item.Name, item.Status, item.Version} + rowsByType[itemType] = append(rowsByType[itemType], row) + } + } + + for itemType, rows := range rowsByType { + t := newTable(out).Writer + t.AppendHeader(table.Row{"Name", "Status", "Version"}) + t.SetTitle(itemType) + t.AppendRows(rows) + fmt.Fprintln(out, t.Render()) + } +} + +func (cli *cliMachines) listHuman(out io.Writer, machines ent.Machines) { + t := newLightTable(out).Writer + t.AppendHeader(table.Row{"Name", "IP Address", "Last Update", "Status", "Version", "OS", "Auth Type", "Last Heartbeat"}) + + for _, m := range machines { + validated := emoji.Prohibited + if m.IsValidated { + validated = emoji.CheckMark + } + + hb, active := getLastHeartbeat(m) + if !active { + hb = emoji.Warning + " " + hb + } + + t.AppendRow(table.Row{m.MachineId, m.IpAddress, m.UpdatedAt.Format(time.RFC3339), validated, m.Version, m.GetOSNameAndVersion(), m.AuthType, hb}) + } + + fmt.Fprintln(out, t.Render()) +} + +// machineInfo contains only the data we want for inspect/list: no hub status, scenarios, edges, etc. +type machineInfo struct { + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastPush *time.Time `json:"last_push,omitempty"` + LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` + MachineId string `json:"machineId,omitempty"` + IpAddress string `json:"ipAddress,omitempty"` + Version string `json:"version,omitempty"` + IsValidated bool `json:"isValidated,omitempty"` + AuthType string `json:"auth_type"` + OS string `json:"os,omitempty"` + Featureflags []string `json:"featureflags,omitempty"` + Datasources map[string]int64 `json:"datasources,omitempty"` +} + +func newMachineInfo(m *ent.Machine) machineInfo { + return machineInfo{ + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + LastPush: m.LastPush, + LastHeartbeat: m.LastHeartbeat, + MachineId: m.MachineId, + IpAddress: m.IpAddress, + Version: m.Version, + IsValidated: m.IsValidated, + AuthType: m.AuthType, + OS: m.GetOSNameAndVersion(), + Featureflags: m.GetFeatureFlagList(), + Datasources: m.Datasources, + } +} + +func (cli *cliMachines) listCSV(out io.Writer, machines ent.Machines) error { + csvwriter := csv.NewWriter(out) + + err := csvwriter.Write([]string{"machine_id", "ip_address", "updated_at", "validated", "version", "auth_type", "last_heartbeat", "os"}) + if err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + for _, m := range machines { + validated := "false" + if m.IsValidated { + validated = "true" + } + + hb := "-" + if m.LastHeartbeat != nil { + hb = m.LastHeartbeat.Format(time.RFC3339) + } + if err := csvwriter.Write([]string{m.MachineId, m.IpAddress, m.UpdatedAt.Format(time.RFC3339), validated, m.Version, m.AuthType, hb, fmt.Sprintf("%s/%s", m.Osname, m.Osversion)}); err != nil { + return fmt.Errorf("failed to write raw output: %w", err) + } + } + + csvwriter.Flush() + return nil +} + + +func (cli *cliMachines) list(out io.Writer) error { machines, err := cli.db.ListMachines() if err != nil { return fmt.Errorf("unable to list machines: %w", err) @@ -161,40 +279,24 @@ func (cli *cliMachines) list() error { switch cli.cfg().Cscli.Output { case "human": - getAgentsTable(out, machines) + cli.listHuman(out, machines) case "json": + info := make([]machineInfo, 0, len(machines)) + for _, m := range machines { + info = append(info, newMachineInfo(m)) + } + enc := json.NewEncoder(out) enc.SetIndent("", " ") - if err := enc.Encode(machines); err != nil { + if err := enc.Encode(info); err != nil { return errors.New("failed to marshal") } return nil case "raw": - csvwriter := csv.NewWriter(out) - - err := csvwriter.Write([]string{"machine_id", "ip_address", "updated_at", "validated", "version", "auth_type", "last_heartbeat"}) - if err != nil { - return fmt.Errorf("failed to write header: %w", err) - } - - for _, m := range machines { - validated := "false" - if m.IsValidated { - validated = "true" - } - - hb, _ := getLastHeartbeat(m) - - if err := csvwriter.Write([]string{m.MachineId, m.IpAddress, m.UpdatedAt.Format(time.RFC3339), validated, m.Version, m.AuthType, hb}); err != nil { - return fmt.Errorf("failed to write raw output: %w", err) - } - } - - csvwriter.Flush() + return cli.listCSV(out, machines) } - return nil } @@ -207,7 +309,7 @@ func (cli *cliMachines) newListCmd() *cobra.Command { Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(_ *cobra.Command, _ []string) error { - return cli.list() + return cli.list(color.Output) }, } @@ -349,13 +451,14 @@ func (cli *cliMachines) add(args []string, machinePassword string, dumpFile stri return nil } -func (cli *cliMachines) deleteValid(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // need to load config and db because PersistentPreRunE is not called for completions - +// validMachineID returns a list of machine IDs for command completion +func (cli *cliMachines) validMachineID(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { var err error cfg := cli.cfg() + // need to load config and db because PersistentPreRunE is not called for completions + if err = require.LAPI(cfg); err != nil { cobra.CompError("unable to list machines " + err.Error()) return nil, cobra.ShellCompDirectiveNoFileComp @@ -405,7 +508,7 @@ func (cli *cliMachines) newDeleteCmd() *cobra.Command { Args: cobra.MinimumNArgs(1), Aliases: []string{"remove"}, DisableAutoGenTag: true, - ValidArgsFunction: cli.deleteValid, + ValidArgsFunction: cli.validMachineID, RunE: func(_ *cobra.Command, args []string) error { return cli.delete(args) }, @@ -417,7 +520,7 @@ func (cli *cliMachines) newDeleteCmd() *cobra.Command { func (cli *cliMachines) prune(duration time.Duration, notValidOnly bool, force bool) error { if duration < 2*time.Minute && !notValidOnly { if yes, err := askYesNo( - "The duration you provided is less than 2 minutes. " + + "The duration you provided is less than 2 minutes. "+ "This can break installations if the machines are only temporarily disconnected. Continue?", false); err != nil { return err } else if !yes { @@ -442,11 +545,11 @@ func (cli *cliMachines) prune(duration time.Duration, notValidOnly bool, force b return nil } - getAgentsTable(color.Output, machines) + cli.listHuman(color.Output, machines) if !force { if yes, err := askYesNo( - "You are about to PERMANENTLY remove the above machines from the database. " + + "You are about to PERMANENTLY remove the above machines from the database. "+ "These will NOT be recoverable. Continue?", false); err != nil { return err } else if !yes { @@ -460,7 +563,7 @@ func (cli *cliMachines) prune(duration time.Duration, notValidOnly bool, force b return fmt.Errorf("unable to prune machines: %w", err) } - fmt.Fprintf(os.Stderr, "successfully delete %d machines\n", deleted) + fmt.Fprintf(os.Stderr, "successfully deleted %d machines\n", deleted) return nil } @@ -521,3 +624,134 @@ func (cli *cliMachines) newValidateCmd() *cobra.Command { return cmd } + +func (*cliMachines) inspectHuman(out io.Writer, machine *ent.Machine) { + t := newTable(out).Writer + + t.SetTitle("Machine: " + machine.MachineId) + + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + }) + + t.AppendRows([]table.Row{ + {"IP Address", machine.IpAddress}, + {"Created At", machine.CreatedAt}, + {"Last Update", machine.UpdatedAt}, + {"Last Heartbeat", machine.LastHeartbeat}, + {"Validated?", machine.IsValidated}, + {"CrowdSec version", machine.Version}, + {"OS", machine.GetOSNameAndVersion()}, + {"Auth type", machine.AuthType}, + }) + + for dsName, dsCount := range machine.Datasources { + t.AppendRow(table.Row{"Datasources", fmt.Sprintf("%s: %d", dsName, dsCount)}) + } + + for _, ff := range machine.GetFeatureFlagList() { + t.AppendRow(table.Row{"Feature Flags", ff}) + } + + for _, coll := range machine.Hubstate[cwhub.COLLECTIONS] { + t.AppendRow(table.Row{"Collections", coll.Name}) + } + + fmt.Fprintln(out, t.Render()) +} + +func (cli *cliMachines) inspect(machine *ent.Machine) error { + out := color.Output + outputFormat := cli.cfg().Cscli.Output + + switch outputFormat { + case "human": + cli.inspectHuman(out, machine) + case "json": + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + + if err := enc.Encode(newMachineInfo(machine)); err != nil { + return errors.New("failed to marshal") + } + + return nil + default: + return fmt.Errorf("output format '%s' not supported for this command", outputFormat) + } + return nil +} + +func (cli *cliMachines) inspectHub(machine *ent.Machine) error { + out := color.Output + + switch cli.cfg().Cscli.Output { + case "human": + cli.inspectHubHuman(out, machine) + case "json": + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + + if err := enc.Encode(machine.Hubstate); err != nil { + return errors.New("failed to marshal") + } + + return nil + case "raw": + csvwriter := csv.NewWriter(out) + + err := csvwriter.Write([]string{"type", "name", "status", "version"}) + if err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + rows := make([][]string, 0) + + for itemType, items := range machine.Hubstate { + for _, item := range items { + rows = append(rows, []string{itemType, item.Name, item.Status, item.Version}) + } + } + + for _, row := range rows { + if err := csvwriter.Write(row); err != nil { + return fmt.Errorf("failed to write raw output: %w", err) + } + } + + csvwriter.Flush() + } + return nil +} + +func (cli *cliMachines) newInspectCmd() *cobra.Command { + var showHub bool + + cmd := &cobra.Command{ + Use: "inspect [machine_name]", + Short: "inspect a machine by name", + Example: `cscli machines inspect "machine1"`, + Args: cobra.ExactArgs(1), + DisableAutoGenTag: true, + ValidArgsFunction: cli.validMachineID, + RunE: func(_ *cobra.Command, args []string) error { + machineID := args[0] + machine, err := cli.db.QueryMachineByID(machineID) + if err != nil { + return fmt.Errorf("unable to read machine data '%s': %w", machineID, err) + } + + if showHub { + return cli.inspectHub(machine) + } + + return cli.inspect(machine) + }, + } + + flags := cmd.Flags() + + flags.BoolVarP(&showHub, "hub", "H", false, "show hub state") + + return cmd +} diff --git a/cmd/crowdsec-cli/machines_table.go b/cmd/crowdsec-cli/machines_table.go deleted file mode 100644 index 18e16bbde3a..00000000000 --- a/cmd/crowdsec-cli/machines_table.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "io" - "time" - - "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/table" - "github.com/crowdsecurity/crowdsec/pkg/database/ent" - "github.com/crowdsecurity/crowdsec/pkg/emoji" -) - -func getAgentsTable(out io.Writer, machines []*ent.Machine) { - t := newLightTable(out) - t.SetHeaders("Name", "IP Address", "Last Update", "Status", "Version", "Auth Type", "Last Heartbeat") - t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft) - t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft) - - for _, m := range machines { - validated := emoji.Prohibited - if m.IsValidated { - validated = emoji.CheckMark - } - - hb, active := getLastHeartbeat(m) - if !active { - hb = emoji.Warning + " " + hb - } - - t.AddRow(m.MachineId, m.IpAddress, m.UpdatedAt.Format(time.RFC3339), validated, m.Version, m.AuthType, hb) - } - - t.Render() -} diff --git a/cmd/crowdsec-cli/support.go b/cmd/crowdsec-cli/support.go index 061733ef8d3..1f98768f778 100644 --- a/cmd/crowdsec-cli/support.go +++ b/cmd/crowdsec-cli/support.go @@ -193,12 +193,9 @@ func (cli *cliSupport) dumpBouncers(zw *zip.Writer, db *database.Client) error { out := new(bytes.Buffer) - bouncers, err := db.ListBouncers() - if err != nil { - return fmt.Errorf("unable to list bouncers: %w", err) - } - - getBouncersTable(out, bouncers) + // call the "cscli bouncers list" command directly, skip any preRun + cm := cliBouncers{db: db, cfg: cli.cfg} + cm.list(out) stripped := stripAnsiString(out.String()) @@ -216,12 +213,9 @@ func (cli *cliSupport) dumpAgents(zw *zip.Writer, db *database.Client) error { out := new(bytes.Buffer) - machines, err := db.ListMachines() - if err != nil { - return fmt.Errorf("unable to list machines: %w", err) - } - - getAgentsTable(out, machines) + // call the "cscli machines list" command directly, skip any preRun + cm := cliMachines{db: db, cfg: cli.cfg} + cm.list(out) stripped := stripAnsiString(out.String()) @@ -617,6 +611,10 @@ cscli support dump -f /tmp/crowdsec-support.zip Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, _ []string) error { + output := cli.cfg().Cscli.Output + if output != "human" { + return fmt.Errorf("output format %s not supported for this command", output) + } return cli.dump(cmd.Context(), outFile) }, } diff --git a/pkg/database/ent/helpers.go b/pkg/database/ent/helpers.go new file mode 100644 index 00000000000..0c19fe76f0a --- /dev/null +++ b/pkg/database/ent/helpers.go @@ -0,0 +1,50 @@ +package ent + +import ( + "strings" +) + + +// XXX: we can DRY here + +func (m *Machine) GetOSNameAndVersion() string { + ret := m.Osname + if m.Osversion != "" { + if ret != "" { + ret += "/" + } + ret += m.Osversion + } + if ret == "" { + return "?" + } + return ret +} + +func (b *Bouncer) GetOSNameAndVersion() string { + ret := b.Osname + if b.Osversion != "" { + if ret != "" { + ret += "/" + } + ret += b.Osversion + } + if ret == "" { + return "?" + } + return ret +} + +func (m *Machine) GetFeatureFlagList() []string { + if m.Featureflags == "" { + return nil + } + return strings.Split(m.Featureflags, ",") +} + +func (b *Bouncer) GetFeatureFlagList() []string { + if b.Featureflags == "" { + return nil + } + return strings.Split(b.Featureflags, ",") +} diff --git a/pkg/database/ent/schema/machine.go b/pkg/database/ent/schema/machine.go index 1566cf70b32..5b68f61b1a0 100644 --- a/pkg/database/ent/schema/machine.go +++ b/pkg/database/ent/schema/machine.go @@ -10,6 +10,7 @@ import ( // ItemState is defined here instead of using pkg/models/HubItem to avoid introducing a dependency type ItemState struct { + Name string `json:"name,omitempty"` Status string `json:"status,omitempty"` Version string `json:"version,omitempty"` } diff --git a/test/bats/30_machines.bats b/test/bats/30_machines.bats index 1af5e97dcb4..f8b63fb3173 100644 --- a/test/bats/30_machines.bats +++ b/test/bats/30_machines.bats @@ -62,7 +62,7 @@ teardown() { assert_output 1 } -@test "machines delete has autocompletion" { +@test "machines [delete|inspect] has autocompletion" { rune -0 cscli machines add -a -f /dev/null foo1 rune -0 cscli machines add -a -f /dev/null foo2 rune -0 cscli machines add -a -f /dev/null bar @@ -72,6 +72,11 @@ teardown() { assert_line --index 1 'foo2' refute_line 'bar' refute_line 'baz' + rune -0 cscli __complete machines inspect 'foo' + assert_line --index 0 'foo1' + assert_line --index 1 'foo2' + refute_line 'bar' + refute_line 'baz' } @test "heartbeat is initially null" { From 789f21d14cff00a18dba7e1370bb6e57366b5072 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 3 Jul 2024 17:06:41 +0200 Subject: [PATCH 2/2] lint --- cmd/crowdsec-cli/machines.go | 54 +++++++++++++++++++----------------- pkg/database/ent/helpers.go | 11 ++++++-- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/cmd/crowdsec-cli/machines.go b/cmd/crowdsec-cli/machines.go index 155d7679833..8796d3de9b8 100644 --- a/cmd/crowdsec-cli/machines.go +++ b/cmd/crowdsec-cli/machines.go @@ -24,14 +24,13 @@ import ( "github.com/crowdsecurity/machineid" + "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require" "github.com/crowdsecurity/crowdsec/pkg/csconfig" "github.com/crowdsecurity/crowdsec/pkg/cwhub" "github.com/crowdsecurity/crowdsec/pkg/database" "github.com/crowdsecurity/crowdsec/pkg/database/ent" "github.com/crowdsecurity/crowdsec/pkg/emoji" "github.com/crowdsecurity/crowdsec/pkg/types" - - "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require" ) const passwordLength = 64 @@ -211,34 +210,34 @@ func (cli *cliMachines) listHuman(out io.Writer, machines ent.Machines) { // machineInfo contains only the data we want for inspect/list: no hub status, scenarios, edges, etc. type machineInfo struct { - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - LastPush *time.Time `json:"last_push,omitempty"` - LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` - MachineId string `json:"machineId,omitempty"` - IpAddress string `json:"ipAddress,omitempty"` - Version string `json:"version,omitempty"` - IsValidated bool `json:"isValidated,omitempty"` - AuthType string `json:"auth_type"` - OS string `json:"os,omitempty"` - Featureflags []string `json:"featureflags,omitempty"` - Datasources map[string]int64 `json:"datasources,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastPush *time.Time `json:"last_push,omitempty"` + LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` + MachineId string `json:"machineId,omitempty"` + IpAddress string `json:"ipAddress,omitempty"` + Version string `json:"version,omitempty"` + IsValidated bool `json:"isValidated,omitempty"` + AuthType string `json:"auth_type"` + OS string `json:"os,omitempty"` + Featureflags []string `json:"featureflags,omitempty"` + Datasources map[string]int64 `json:"datasources,omitempty"` } func newMachineInfo(m *ent.Machine) machineInfo { return machineInfo{ - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, - LastPush: m.LastPush, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + LastPush: m.LastPush, LastHeartbeat: m.LastHeartbeat, - MachineId: m.MachineId, - IpAddress: m.IpAddress, - Version: m.Version, - IsValidated: m.IsValidated, - AuthType: m.AuthType, - OS: m.GetOSNameAndVersion(), - Featureflags: m.GetFeatureFlagList(), - Datasources: m.Datasources, + MachineId: m.MachineId, + IpAddress: m.IpAddress, + Version: m.Version, + IsValidated: m.IsValidated, + AuthType: m.AuthType, + OS: m.GetOSNameAndVersion(), + Featureflags: m.GetFeatureFlagList(), + Datasources: m.Datasources, } } @@ -267,10 +266,10 @@ func (cli *cliMachines) listCSV(out io.Writer, machines ent.Machines) error { } csvwriter.Flush() + return nil } - func (cli *cliMachines) list(out io.Writer) error { machines, err := cli.db.ListMachines() if err != nil { @@ -297,6 +296,7 @@ func (cli *cliMachines) list(out io.Writer) error { case "raw": return cli.listCSV(out, machines) } + return nil } @@ -679,6 +679,7 @@ func (cli *cliMachines) inspect(machine *ent.Machine) error { default: return fmt.Errorf("output format '%s' not supported for this command", outputFormat) } + return nil } @@ -721,6 +722,7 @@ func (cli *cliMachines) inspectHub(machine *ent.Machine) error { csvwriter.Flush() } + return nil } diff --git a/pkg/database/ent/helpers.go b/pkg/database/ent/helpers.go index 0c19fe76f0a..c6cdbd7f32b 100644 --- a/pkg/database/ent/helpers.go +++ b/pkg/database/ent/helpers.go @@ -4,20 +4,20 @@ import ( "strings" ) - -// XXX: we can DRY here - func (m *Machine) GetOSNameAndVersion() string { ret := m.Osname if m.Osversion != "" { if ret != "" { ret += "/" } + ret += m.Osversion } + if ret == "" { return "?" } + return ret } @@ -27,11 +27,14 @@ func (b *Bouncer) GetOSNameAndVersion() string { if ret != "" { ret += "/" } + ret += b.Osversion } + if ret == "" { return "?" } + return ret } @@ -39,6 +42,7 @@ func (m *Machine) GetFeatureFlagList() []string { if m.Featureflags == "" { return nil } + return strings.Split(m.Featureflags, ",") } @@ -46,5 +50,6 @@ func (b *Bouncer) GetFeatureFlagList() []string { if b.Featureflags == "" { return nil } + return strings.Split(b.Featureflags, ",") }