github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/config/remoteconfig/messages.go (about)

     1  package remoteconfig
     2  
     3  import (
     4  	"math/rand"
     5  	"strings"
     6  	"time"
     7  
     8  	"github.com/ddev/ddev/pkg/config/remoteconfig/internal"
     9  	"github.com/ddev/ddev/pkg/config/remoteconfig/types"
    10  	"github.com/ddev/ddev/pkg/dockerutil"
    11  	"github.com/ddev/ddev/pkg/nodeps"
    12  	"github.com/ddev/ddev/pkg/output"
    13  	"github.com/ddev/ddev/pkg/styles"
    14  	"github.com/ddev/ddev/pkg/util"
    15  	"github.com/ddev/ddev/pkg/versionconstants"
    16  	"github.com/jedib0t/go-pretty/v6/table"
    17  	"github.com/jedib0t/go-pretty/v6/text"
    18  )
    19  
    20  type messageTypes struct {
    21  	messageType types.MessageType
    22  	messages    []internal.Message
    23  }
    24  
    25  type conditionDefinition struct {
    26  	name          string
    27  	description   string
    28  	conditionFunc func() bool
    29  }
    30  
    31  var conditionDefinitions = map[string]conditionDefinition{}
    32  
    33  func init() {
    34  	AddCondition("Disabled", "Permanently disables the message", func() bool { return false })
    35  	AddCondition("Colima", "Running on Colima", dockerutil.IsColima)
    36  	AddCondition("Lima", "Running on Lima", dockerutil.IsLima)
    37  	AddCondition("DockerDesktop", "Running on Docker Desktop", dockerutil.IsDockerDesktop)
    38  	AddCondition("WSL2", "Running on WSL2", nodeps.IsWSL2)
    39  }
    40  
    41  func AddCondition(name, description string, conditionFunc func() bool) {
    42  	conditionDefinitions[strings.ToLower(name)] = conditionDefinition{
    43  		name:          name,
    44  		description:   description,
    45  		conditionFunc: conditionFunc,
    46  	}
    47  }
    48  
    49  func ListConditions() (conditions map[string]string) {
    50  	conditions = make(map[string]string)
    51  
    52  	for _, condition := range conditionDefinitions {
    53  		conditions[condition.name] = condition.description
    54  	}
    55  
    56  	return
    57  }
    58  
    59  // ShowNotifications shows notifications provided by the remote config to the user.
    60  func (c *remoteConfig) ShowNotifications() {
    61  	// defer util.TimeTrack()()
    62  
    63  	if !c.showNotifications() {
    64  		return
    65  	}
    66  
    67  	for _, messages := range []messageTypes{
    68  		{messageType: types.Info, messages: c.remoteConfig.Messages.Notifications.Infos},
    69  		{messageType: types.Warning, messages: c.remoteConfig.Messages.Notifications.Warnings},
    70  	} {
    71  		t := table.NewWriter()
    72  
    73  		var title string
    74  		var i int
    75  
    76  		switch messages.messageType {
    77  		case types.Warning:
    78  			applyTableStyle(warning, t)
    79  			title = "Important Warning"
    80  		default:
    81  			applyTableStyle(information, t)
    82  			title = "Important Message"
    83  		}
    84  
    85  		for _, message := range messages.messages {
    86  			if !c.checkConditions(message.Conditions) || !c.checkVersions(message.Versions) {
    87  				continue
    88  			}
    89  
    90  			t.AppendRow(table.Row{message.Message})
    91  			i++
    92  		}
    93  
    94  		if i == 0 {
    95  			continue
    96  		}
    97  
    98  		if i > 1 {
    99  			title += "s"
   100  		}
   101  
   102  		t.AppendHeader(table.Row{title})
   103  
   104  		output.UserOut.Print("\n", t.Render(), "\n")
   105  	}
   106  
   107  	c.state.LastNotificationAt = time.Now()
   108  	if err := c.state.save(); err != nil {
   109  		util.Debug("Error while saving state: %s", err)
   110  	}
   111  }
   112  
   113  // ShowTicker shows ticker messages provided by the remote config to the user.
   114  func (c *remoteConfig) ShowTicker() {
   115  	// defer util.TimeTrack()()
   116  
   117  	if !c.showTickerMessage() || len(c.remoteConfig.Messages.Ticker.Messages) == 0 {
   118  		return
   119  	}
   120  
   121  	messageOffset := c.state.LastTickerMessage
   122  	messageCount := len(c.remoteConfig.Messages.Ticker.Messages)
   123  
   124  	if messageOffset == 0 {
   125  		// As long as no message was shown, start with a random message. This
   126  		// is important for short living instances e.g. Gitpod to not always
   127  		// show the first message. A number from 0 to number of messages minus
   128  		// 1 is generated.
   129  		messageOffset = rand.Intn(messageCount)
   130  	}
   131  
   132  	for i := range c.remoteConfig.Messages.Ticker.Messages {
   133  		messageOffset++
   134  		if messageOffset > messageCount {
   135  			messageOffset = 1
   136  		}
   137  
   138  		message := &c.remoteConfig.Messages.Ticker.Messages[i+messageOffset-1]
   139  
   140  		if c.checkConditions(message.Conditions) && c.checkVersions(message.Versions) {
   141  			t := table.NewWriter()
   142  			applyTableStyle(ticker, t)
   143  
   144  			var title string
   145  
   146  			if message.Title != "" {
   147  				title = message.Title
   148  			} else {
   149  				title = "Tip of the day"
   150  			}
   151  
   152  			t.AppendHeader(table.Row{title})
   153  			t.AppendRow(table.Row{message.Message})
   154  
   155  			output.UserOut.Print("\n", t.Render(), "\n")
   156  
   157  			c.state.LastTickerMessage = messageOffset
   158  			c.state.LastTickerAt = time.Now()
   159  			if err := c.state.save(); err != nil {
   160  				util.Debug("Error while saving state: %s", err)
   161  			}
   162  
   163  			break
   164  		}
   165  	}
   166  }
   167  
   168  // isNotificationsDisabled returns true if notifications should not be shown to
   169  // the user which can be achieved by setting the related remote config.
   170  func (c *remoteConfig) isNotificationsDisabled() bool {
   171  	return c.getNotificationsInterval() < 0
   172  }
   173  
   174  // getNotificationsInterval returns the notifications interval. The processing
   175  // order is defined as follows, the first defined value is returned:
   176  //   - remote config
   177  //   - const notificationsInterval
   178  func (c *remoteConfig) getNotificationsInterval() time.Duration {
   179  	if c.remoteConfig.Messages.Notifications.Interval != 0 {
   180  		return time.Duration(c.remoteConfig.Messages.Notifications.Interval) * time.Hour
   181  	}
   182  
   183  	return time.Duration(notificationsInterval) * time.Hour
   184  }
   185  
   186  // showNotifications returns true if notifications are not disabled and the
   187  // notifications interval has been elapsed.
   188  func (c *remoteConfig) showNotifications() bool {
   189  	return !output.JSONOutput &&
   190  		!c.isNotificationsDisabled() &&
   191  		c.state.LastNotificationAt.Add(c.getNotificationsInterval()).Before(time.Now())
   192  }
   193  
   194  // isTickerDisabled returns true if tips should not be shown to the user which
   195  // can be achieved by setting the related global config or also via the remote
   196  // config.
   197  func (c *remoteConfig) isTickerDisabled() bool {
   198  	return c.getTickerInterval() < 0
   199  }
   200  
   201  // getTickerInterval returns the ticker interval. The processing order is
   202  // defined as follows, the first defined value is returned:
   203  //   - global config
   204  //   - remote config
   205  //   - const tickerInterval
   206  func (c *remoteConfig) getTickerInterval() time.Duration {
   207  	if c.tickerInterval != 0 {
   208  		return time.Duration(c.tickerInterval) * time.Hour
   209  	}
   210  
   211  	if c.remoteConfig.Messages.Ticker.Interval != 0 {
   212  		return time.Duration(c.remoteConfig.Messages.Ticker.Interval) * time.Hour
   213  	}
   214  
   215  	return time.Duration(tickerInterval) * time.Hour
   216  }
   217  
   218  // showTickerMessage returns true if the ticker is not disabled and the ticker
   219  // interval has been elapsed.
   220  func (c *remoteConfig) showTickerMessage() bool {
   221  	return !output.JSONOutput &&
   222  		!c.isTickerDisabled() &&
   223  		c.state.LastTickerAt.Add(c.getTickerInterval()).Before(time.Now())
   224  }
   225  
   226  func (c *remoteConfig) checkConditions(conditions []string) bool {
   227  	for _, rawCondition := range conditions {
   228  		condition, negated := strings.CutPrefix(strings.TrimSpace(rawCondition), "!")
   229  		condition = strings.ToLower(strings.TrimSpace(condition))
   230  
   231  		conditionDef, found := conditionDefinitions[condition]
   232  
   233  		if found {
   234  			conditionResult := conditionDef.conditionFunc()
   235  
   236  			if (!negated && !conditionResult) || (negated && conditionResult) {
   237  				return false
   238  			}
   239  		}
   240  	}
   241  
   242  	return true
   243  }
   244  
   245  func (c *remoteConfig) checkVersions(versions string) bool {
   246  	versions = strings.TrimSpace(versions)
   247  	if versions != "" {
   248  		match, err := util.SemverValidate(versions, versionconstants.DdevVersion)
   249  		if err != nil {
   250  			util.Debug("Failed to validate DDEV version `%s` against constraint `%s`: %s", versionconstants.DdevVersion, versions, err)
   251  			return true
   252  		}
   253  
   254  		return match
   255  	}
   256  
   257  	return true
   258  }
   259  
   260  type preset int
   261  
   262  const (
   263  	information preset = iota
   264  	warning
   265  	ticker
   266  )
   267  
   268  func applyTableStyle(preset preset, writer table.Writer) {
   269  	styles.SetGlobalTableStyle(writer)
   270  
   271  	termWidth, _ := nodeps.GetTerminalWidthHeight()
   272  	util.Debug("termWidth: %d", termWidth)
   273  	writer.SetColumnConfigs([]table.ColumnConfig{
   274  		{
   275  			Number:           1,
   276  			WidthMin:         50,
   277  			WidthMax:         int(termWidth) - 5,
   278  			WidthMaxEnforcer: text.WrapSoft,
   279  		},
   280  	})
   281  
   282  	style := writer.Style()
   283  
   284  	style.Options.SeparateRows = false
   285  	style.Options.SeparateFooter = false
   286  	style.Options.SeparateColumns = false
   287  	style.Options.SeparateHeader = false
   288  	style.Options.DrawBorder = false
   289  
   290  	switch preset {
   291  	case information:
   292  		style.Color = table.ColorOptions{
   293  			Header: text.Colors{text.BgHiYellow, text.FgBlack},
   294  			Row:    text.Colors{text.BgHiYellow, text.FgBlack},
   295  		}
   296  	case warning:
   297  		style.Color = table.ColorOptions{
   298  			Header: text.Colors{text.BgHiRed, text.FgBlack},
   299  			Row:    text.Colors{text.BgHiRed, text.FgBlack},
   300  		}
   301  	case ticker:
   302  		style.Color = table.ColorOptions{
   303  			Header: text.Colors{text.BgHiWhite, text.FgBlack},
   304  			Row:    text.Colors{text.BgHiWhite, text.FgBlack},
   305  		}
   306  	}
   307  }