Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cmd/insights): Udeng 5696 initial client core cli #2

Merged
merged 22 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
63 changes: 63 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# This is for linting. To run it, please use:
# golangci-lint run ${MODULE}/... [--fix]

linters:
# linters to run in addition to default ones
enable:
- copyloopvar
- dupl
- durationcheck
- errname
- errorlint
- forbidigo
- forcetypeassert
- gci
- godot
- gofmt
- gosec
- misspell
- nakedret
- nolintlint
- revive
- thelper
- tparallel
- unconvert
- unparam
- whitespace

run:
timeout: 5m

# Get all linter issues, even if duplicated
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
fix: false # we don’t want this in CI
exclude:
# EXC0001 errcheck: most errors are in defer calls, which are safe to ignore and idiomatic Go (would be good to only ignore defer ones though)
- 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv|w\.Stop). is not checked'
# EXC0008 gosec: duplicated of errcheck
- (G104|G307)
# EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)'
- Potential file inclusion via variable
# We don't wrap errors on purpose
- non-wrapping format verb for fmt.Errorf. Use `%w` to format errors
# We want named parameters even if unused, as they help better document the function
- unused-parameter
# Sometimes it is more readable it do a `if err:=a(); err != nil` tha simpy `return a()`
- if-return

nolintlint:
require-explanation: true
require-specific: true

linters-settings:
# Forbid the usage of deprecated ioutil and debug prints
forbidigo:
forbid:
- ioutil\.
- ^print.*$
# Never have naked return ever
nakedret:
max-func-lines: 1
1 change: 1 addition & 0 deletions cmd/exposed-server/daemon/daemon.go
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
// Package daemon is responsible for running the exposed-server in the background.
package daemon
1 change: 1 addition & 0 deletions cmd/exposed-server/main.go
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
// Package main for the exposed-server.
package main
1 change: 1 addition & 0 deletions cmd/ingest-server/daemon/daemon.go
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
// Package daemon is responsible for running the ingest-server in the background.
package daemon
1 change: 1 addition & 0 deletions cmd/ingest-server/main.go
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
// Package main for the ingest-server.
package main
76 changes: 76 additions & 0 deletions cmd/insights/commands/collect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package commands

import (
"fmt"
"log/slog"
"os"

"github.com/spf13/cobra"
)

type collectConfig struct {
source string
period uint
force bool
dryRun bool
extraMetrics string
}

var defaultCollectConfig = collectConfig{
source: "",
period: 1,
force: false,
dryRun: false,
extraMetrics: "",
}

func installCollectCmd(app *App) {
app.collectConfig = defaultCollectConfig

collectCmd := &cobra.Command{
Use: "collect [SOURCE] [SOURCE-METRICS-PATH](required if source provided)",
Short: "Collect system information",
Long: `Collect system information and metrics and store it locally.
If SOURCE is not provided, then it is the source is assumed to be the currently detected platform. Additionally, there should be no SOURCE-METRICS-PATH provided.
If SOURCE is provided, then the SOURCE-METRICS-PATH should be provided as well.`,
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.MaximumNArgs(2)(cmd, args); err != nil {
return err
}

if len(args) != 0 {
if err := cobra.MatchAll(cobra.OnlyValidArgs, cobra.ExactArgs(2))(cmd, args); err != nil {
return fmt.Errorf("accepts no args, or exactly 2 args, received 1")
}

fileInfo, err := os.Stat(args[1])
if err != nil {
return fmt.Errorf("the second argument, source-metrics-path, should be a valid JSON file. Error: %s", err.Error())
}

if fileInfo.IsDir() {
return fmt.Errorf("the second argument, source-metrics-path, should be a valid JSON file, not a directory")
}
}

return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
// Set Sources to Args
if len(args) == 2 {
app.collectConfig.source = args[0]
app.collectConfig.extraMetrics = args[1]
}

slog.Info("Running collect command")

return nil
},
}

collectCmd.Flags().UintVarP(&app.collectConfig.period, "period", "p", 1, "the minimum period between 2 collection periods for validation purposes in seconds")
collectCmd.Flags().BoolVarP(&app.collectConfig.force, "force", "f", false, "force a collection, override the report if there are any conflicts (doesn't ignore consent)")
collectCmd.Flags().BoolVarP(&app.collectConfig.dryRun, "dry-run", "d", false, "perform a dry-run where a report is collected, but not written to disk")

app.rootCmd.AddCommand(collectCmd)
}
1 change: 1 addition & 0 deletions cmd/insights/commands/collect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package commands
61 changes: 61 additions & 0 deletions cmd/insights/commands/consent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package commands

import (
"fmt"
"log/slog"
"slices"
"strings"

"github.com/spf13/cobra"
)

type consentConfig struct {
sources []string
consentState string
}

var defaultConsentConfig = consentConfig{
sources: []string{""},
consentState: "",
}

func installConsentCmd(app *App) {
app.consentConfig = defaultConsentConfig

consentCmd := &cobra.Command{
Use: "consent [SOURCES](optional arguments)",
Short: "Manage or get user consent state",
Long: "Manage or get user consent state for data collection and upload",
Args: cobra.ArbitraryArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
app.rootCmd.SilenceUsage = false

validConsentStates := []string{"true", "false", ""}
if !slices.Contains(validConsentStates, strings.ToLower(app.consentConfig.consentState)) {
return fmt.Errorf("consent-state must be either true, false, or not set")
}

app.rootCmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
// Set Sources to Args
app.consentConfig.sources = args

// Ensure consent state is case insensitive
app.consentConfig.consentState = strings.ToLower(app.consentConfig.consentState)
Sploder12 marked this conversation as resolved.
Show resolved Hide resolved

// If insights-dir is set, warn the user that it is not used
if app.rootConfig.InsightsDir != defaultRootConfig.InsightsDir {
slog.Warn("The insights-dir flag was provided but it is not used in the consent command")
}

slog.Info("Running consent command")
return nil
},
}

consentCmd.Flags().StringVarP(&app.consentConfig.consentState, "consent-state", "c", "", "the consent state to set (true or false), the current consent state is displayed if not set")

app.rootCmd.AddCommand(consentCmd)
}
1 change: 1 addition & 0 deletions cmd/insights/commands/consent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package commands
103 changes: 103 additions & 0 deletions cmd/insights/commands/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Package commands contains the commands for the Ubuntu Insights CLI.
package commands

import (
"log/slog"

"github.com/spf13/cobra"
"github.com/ubuntu/ubuntu-insights/internal/constants"
)

// App represents the application.
type App struct {
rootCmd *cobra.Command

rootConfig rootConfig
collectConfig collectConfig
uploadConfig uploadConfig
consentConfig consentConfig
}

type rootConfig struct {
Verbose bool
ConsentDir string
InsightsDir string
}

var defaultRootConfig = rootConfig{
Verbose: false,
ConsentDir: constants.GetDefaultConfigPath(),
InsightsDir: constants.GetDefaultCachePath(),
}

// New registers commands and returns a new App.
func New() (*App, error) {
a := App{}
a.rootCmd = &cobra.Command{
Use: constants.CmdName + " [COMMAND]",
Short: "",
Long: "",
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Command parsing has been successful. Returns to not print usage anymore.
a.rootCmd.SilenceUsage = true

setVerbosity(a.rootConfig.Verbose)
return nil
},
}

err := installRootCmd(&a)
installCollectCmd(&a)
installUploadCmd(&a)
installConsentCmd(&a)

return &a, err
}

func installRootCmd(app *App) error {
cmd := app.rootCmd

app.rootConfig = defaultRootConfig

cmd.PersistentFlags().BoolVarP(&app.rootConfig.Verbose, "verbose", "v", app.rootConfig.Verbose, "enable verbose logging")
cmd.PersistentFlags().StringVar(&app.rootConfig.ConsentDir, "consent-dir", app.rootConfig.ConsentDir, "the base directory of the consent state files")
cmd.PersistentFlags().StringVar(&app.rootConfig.InsightsDir, "insights-dir", app.rootConfig.InsightsDir, "the base directory of the insights report cache")

if err := cmd.MarkPersistentFlagDirname("consent-dir"); err != nil {
slog.Error("An error occurred while initializing Ubuntu Insights", "error", err.Error())
return err
}

if err := cmd.MarkPersistentFlagDirname("insights-dir"); err != nil {
slog.Error("An error occurred while initializing Ubuntu Insights.", "error", err.Error())
return err
}

return nil
}

// setVerbosity sets the global logging level based on the verbose flag. If verbose is true, it sets the logging level to debug, otherwise it sets it to info.
func setVerbosity(verbose bool) {
if verbose {
slog.SetLogLoggerLevel(slog.LevelDebug)
slog.Debug("Verbose logging enabled")
} else {
slog.SetLogLoggerLevel(constants.DefaultLogLevel)
}
}

// Run executes the command and associated process, returning an error if any.
func (a *App) Run() error {
return a.rootCmd.Execute()
}

// UsageError returns if the error is a command parsing or runtime one.
func (a App) UsageError() bool {
return !a.rootCmd.SilenceUsage
}

// Quit gracefully exits the application.
func (a App) Quit() {
// Not implemented
}
61 changes: 61 additions & 0 deletions cmd/insights/commands/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package commands

import (
"context"
"log/slog"
"testing"

"github.com/stretchr/testify/assert"
"github.com/ubuntu/ubuntu-insights/internal/constants"
)

func TestSetVerbosity(t *testing.T) {
testCases := []struct {
name string
pattern []bool
}{
{
name: "true",
pattern: []bool{true},
},
{
name: "false",
pattern: []bool{false},
},
{
name: "true false",
pattern: []bool{true, false},
},
{
name: "false true",
pattern: []bool{false, true},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for _, p := range tc.pattern {
setVerbosity(p)

if p {
assert.True(t, slog.Default().Enabled(context.Background(), slog.LevelDebug))
} else {
assert.True(t, slog.Default().Enabled(context.Background(), constants.DefaultLogLevel))
}
}
})
}
}

func TestUsageError(t *testing.T) {
app, err := New()
assert.NoError(t, err)

// Test when SilenceUsage is true
app.rootCmd.SilenceUsage = true
assert.False(t, app.UsageError())

// Test when SilenceUsage is false
app.rootCmd.SilenceUsage = false
assert.True(t, app.UsageError())
}
Loading
Loading