get.porter.sh/porter@v1.3.0/pkg/linter/linter.go (about)

     1  package linter
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"strings"
     8  
     9  	"get.porter.sh/porter/pkg/config"
    10  	"get.porter.sh/porter/pkg/manifest"
    11  	"get.porter.sh/porter/pkg/mixin/query"
    12  	"get.porter.sh/porter/pkg/pkgmgmt"
    13  	"get.porter.sh/porter/pkg/portercontext"
    14  	"get.porter.sh/porter/pkg/tracing"
    15  	"get.porter.sh/porter/pkg/yaml"
    16  	"github.com/Masterminds/semver/v3"
    17  	"github.com/dustin/go-humanize"
    18  )
    19  
    20  // Level of severity for a lint result.
    21  type Level int
    22  
    23  func (l Level) String() string {
    24  	switch l {
    25  	case LevelError:
    26  		return "error"
    27  	case LevelWarning:
    28  		return "warning"
    29  	}
    30  	return ""
    31  }
    32  
    33  // Code representing the problem identified by the linter
    34  // Recommended to use the pattern MIXIN-NUMBER so that you don't collide with
    35  // codes from another mixin or with Porter's codes.
    36  // Example:
    37  // - exec-105
    38  // - helm-410
    39  type Code string
    40  
    41  const (
    42  	// LevelError indicates a lint result is an error that will prevent the bundle from building properly.
    43  	LevelError Level = 0
    44  
    45  	// LevelWarning indicates a lint result is a warning about a best practice or identifies a problem that is not
    46  	// guaranteed to break the build.
    47  	LevelWarning Level = 2
    48  )
    49  
    50  // Result is a single item identified by the linter.
    51  type Result struct {
    52  	// Level of severity
    53  	Level Level
    54  
    55  	// Location of the problem in the manifest.
    56  	Location Location
    57  
    58  	// Code uniquely identifying the type of problem.
    59  	Code Code
    60  
    61  	// Title to display (80 chars).
    62  	Title string
    63  
    64  	// Message explaining the problem.
    65  	Message string
    66  
    67  	// URL that provides additional assistance with this problem.
    68  	URL string
    69  }
    70  
    71  func (r Result) String() string {
    72  	var buffer strings.Builder
    73  	buffer.WriteString(fmt.Sprintf("%s(%s) - %s\n", r.Level, r.Code, r.Title))
    74  	if r.Location.Mixin != "" {
    75  		buffer.WriteString(r.Location.String() + "\n")
    76  	}
    77  
    78  	if r.Message != "" {
    79  		buffer.WriteString(r.Message + "\n")
    80  	}
    81  
    82  	if r.URL != "" {
    83  		buffer.WriteString(fmt.Sprintf("See %s for more information\n", r.URL))
    84  	}
    85  
    86  	buffer.WriteString("---\n")
    87  	return buffer.String()
    88  }
    89  
    90  // Location identifies the offending mixin step within a manifest.
    91  type Location struct {
    92  	// Action containing the step, e.g. Install.
    93  	Action string
    94  
    95  	// Mixin name, e.g. exec.
    96  	Mixin string
    97  
    98  	// StepNumber is the position of the step, starting from 1, within the action.
    99  	// Example
   100  	// install:
   101  	//  - exec: (1)
   102  	//     ...
   103  	//  - helm3: (2)
   104  	//     ...
   105  	//  - exec: (3)
   106  	//     ...
   107  	StepNumber int
   108  
   109  	// StepDescription is the description of the step provided in the manifest.
   110  	// Example
   111  	// install:
   112  	//  - exec:
   113  	//      description: THIS IS THE STEP DESCRIPTION
   114  	//      command: ./helper.sh
   115  	StepDescription string
   116  }
   117  
   118  func (l Location) String() string {
   119  	return fmt.Sprintf("%s: %s step in the %s mixin (%s)",
   120  		l.Action, humanize.Ordinal(l.StepNumber), l.Mixin, l.StepDescription)
   121  }
   122  
   123  // Results is a set of items identified by the linter.
   124  type Results []Result
   125  
   126  func (r Results) String() string {
   127  	var buffer strings.Builder
   128  	// TODO: Sort, display errors first
   129  	for _, result := range r {
   130  		buffer.WriteString(result.String())
   131  	}
   132  
   133  	return buffer.String()
   134  }
   135  
   136  // HasError checks if any of the results is an error.
   137  func (r Results) HasError() bool {
   138  	for _, result := range r {
   139  		if result.Level == LevelError {
   140  			return true
   141  		}
   142  	}
   143  	return false
   144  }
   145  
   146  // Linter manages executing the lint command for all affected mixins and reporting
   147  // the results.
   148  type Linter struct {
   149  	*portercontext.Context
   150  	Mixins pkgmgmt.PackageManager
   151  }
   152  
   153  func New(cxt *portercontext.Context, mixins pkgmgmt.PackageManager) *Linter {
   154  	return &Linter{
   155  		Context: cxt,
   156  		Mixins:  mixins,
   157  	}
   158  }
   159  
   160  type action struct {
   161  	name  string
   162  	steps manifest.Steps
   163  }
   164  
   165  func (l *Linter) Lint(ctx context.Context, m *manifest.Manifest, config *config.Config) (Results, error) {
   166  	// Check for reserved porter prefix on parameter names
   167  	reservedPrefixes := []string{"porter-", "porter_"}
   168  	params := m.Parameters
   169  
   170  	var results Results
   171  
   172  	for _, param := range params {
   173  		paramName := strings.ToLower(param.Name)
   174  		for _, reservedPrefix := range reservedPrefixes {
   175  			if strings.HasPrefix(paramName, reservedPrefix) {
   176  
   177  				res := Result{
   178  					Level: LevelError,
   179  					Location: Location{
   180  						Action:          "",
   181  						Mixin:           "",
   182  						StepNumber:      0,
   183  						StepDescription: "",
   184  					},
   185  					Code:    "porter-100",
   186  					Title:   "Reserved name error",
   187  					Message: param.Name + " has a reserved prefix. Parameters cannot start with porter- or porter_",
   188  					URL:     "https://porter.sh/reference/linter/#porter-100",
   189  				}
   190  				results = append(results, res)
   191  			}
   192  		}
   193  	}
   194  
   195  	// Check if parameters apply to the steps
   196  	ctx, span := tracing.StartSpan(ctx)
   197  	defer span.EndSpan()
   198  
   199  	span.Debug("Validating that parameters applies to the actions...")
   200  	tmplParams := m.GetTemplatedParameters()
   201  	actions := []action{
   202  		{"install", m.Install},
   203  		{"upgrade", m.Upgrade},
   204  		{"uninstall", m.Uninstall},
   205  	}
   206  	for actionName, steps := range m.CustomActions {
   207  		actions = append(actions, action{actionName, steps})
   208  	}
   209  	for _, action := range actions {
   210  		res, err := validateParamsAppliesToAction(m, action.steps, tmplParams, action.name, config)
   211  		if err != nil {
   212  			return nil, span.Error(fmt.Errorf("error validating action: %s", action.name))
   213  		}
   214  		results = append(results, res...)
   215  	}
   216  
   217  	deps := make(map[string]interface{}, len(m.Dependencies.Requires))
   218  	for _, dep := range m.Dependencies.Requires {
   219  		if _, exists := deps[dep.Name]; exists {
   220  			res := Result{
   221  				Level: LevelError,
   222  				Location: Location{
   223  					Action:          "",
   224  					Mixin:           "",
   225  					StepNumber:      0,
   226  					StepDescription: "",
   227  				},
   228  				Code:    "porter-102",
   229  				Title:   "Dependency error",
   230  				Message: fmt.Sprintf("The dependency %s is defined multiple times", dep.Name),
   231  				URL:     "https://porter.sh/reference/linter/#porter-102",
   232  			}
   233  			results = append(results, res)
   234  		} else {
   235  			deps[dep.Name] = nil
   236  		}
   237  	}
   238  
   239  	span.Debug("Running linters for each mixin used in the manifest...")
   240  	q := query.New(l.Context, l.Mixins)
   241  	responses, err := q.Execute(ctx, "lint", query.NewManifestGenerator(m))
   242  	if err != nil {
   243  		return nil, span.Error(err)
   244  	}
   245  
   246  	for _, response := range responses {
   247  		if response.Error != nil {
   248  			// Ignore mixins that do not support the lint command
   249  			if strings.Contains(response.Error.Error(), "unknown command") {
   250  				continue
   251  			}
   252  			// put a helpful error when the mixin is not installed
   253  			if strings.Contains(response.Error.Error(), "not installed") {
   254  				return nil, span.Error(fmt.Errorf("mixin %[1]s is not currently installed. To find view more details you can run: porter mixin search %[1]s. To install you can run porter mixin install %[1]s", response.Name))
   255  			}
   256  			return nil, span.Error(fmt.Errorf("lint command failed for mixin %s: %s", response.Name, response.Stdout))
   257  		}
   258  
   259  		var r Results
   260  		err = json.Unmarshal([]byte(response.Stdout), &r)
   261  		if err != nil {
   262  			return nil, span.Error(fmt.Errorf("unable to parse lint response from mixin %s: %w", response.Name, err))
   263  		}
   264  
   265  		results = append(results, r...)
   266  	}
   267  
   268  	span.Debug("Getting versions for each mixin used in the manifest...")
   269  	err = l.validateVersionNumberConstraints(ctx, m)
   270  	if err != nil {
   271  		return nil, span.Error(err)
   272  	}
   273  
   274  	return results, nil
   275  }
   276  
   277  func (l *Linter) validateVersionNumberConstraints(ctx context.Context, m *manifest.Manifest) error {
   278  	for _, mixin := range m.Mixins {
   279  		if mixin.Version != nil {
   280  			installedMeta, err := l.Mixins.GetMetadata(ctx, mixin.Name)
   281  			if err != nil {
   282  				return fmt.Errorf("unable to get metadata from mixin %s: %w", mixin.Name, err)
   283  			}
   284  			installedVersion := installedMeta.GetVersionInfo().Version
   285  
   286  			err = validateSemverConstraint(mixin.Name, installedVersion, mixin.Version)
   287  			if err != nil {
   288  				return err
   289  			}
   290  		}
   291  	}
   292  
   293  	return nil
   294  }
   295  
   296  func validateSemverConstraint(name string, installedVersion string, versionConstraint *semver.Constraints) error {
   297  	v, err := semver.NewVersion(installedVersion)
   298  	if err != nil {
   299  		return fmt.Errorf("invalid version number from mixin %s: %s. %w", name, installedVersion, err)
   300  	}
   301  
   302  	if !versionConstraint.Check(v) {
   303  		return fmt.Errorf("mixin %s is installed at version %s but your bundle requires version %s", name, installedVersion, versionConstraint)
   304  	}
   305  	return nil
   306  }
   307  
   308  func validateParamsAppliesToAction(m *manifest.Manifest, steps manifest.Steps, tmplParams manifest.ParameterDefinitions, actionName string, config *config.Config) (Results, error) {
   309  	var results Results
   310  	for stepNumber, step := range steps {
   311  		data, err := yaml.Marshal(step.Data)
   312  		if err != nil {
   313  			return nil, fmt.Errorf("error during marshalling: %w", err)
   314  		}
   315  
   316  		tmplResult, err := m.ScanManifestTemplating(data, config)
   317  		if err != nil {
   318  			return nil, fmt.Errorf("error parsing templating: %w", err)
   319  		}
   320  
   321  		for _, variable := range tmplResult.Variables {
   322  			paramName, ok := m.GetTemplateParameterName(variable)
   323  			if !ok {
   324  				continue
   325  			}
   326  
   327  			for _, tmplParam := range tmplParams {
   328  				if tmplParam.Name != paramName {
   329  					continue
   330  				}
   331  				if !tmplParam.AppliesTo(actionName) {
   332  					description, err := step.GetDescription()
   333  					if err != nil {
   334  						return nil, fmt.Errorf("error getting step description: %w", err)
   335  					}
   336  					res := Result{
   337  						Level: LevelError,
   338  						Location: Location{
   339  							Action:          actionName,
   340  							Mixin:           step.GetMixinName(),
   341  							StepNumber:      stepNumber + 1,
   342  							StepDescription: description,
   343  						},
   344  						Code:    "porter-101",
   345  						Title:   "Parameter does not apply to action",
   346  						Message: fmt.Sprintf("Parameter %s does not apply to %s action", paramName, actionName),
   347  						URL:     "https://porter.sh/docs/references/linter/#porter-101",
   348  					}
   349  					results = append(results, res)
   350  				}
   351  			}
   352  		}
   353  	}
   354  
   355  	return results, nil
   356  }