github.com/drone/go-convert@v0.0.0-20240307072510-6bd371c65e61/convert/gitlab/convert.go (about)

     1  // Copyright 2022 Harness, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package gitlab converts Gitlab pipelines to Harness pipelines.
    16  package gitlab
    17  
    18  import (
    19  	"bytes"
    20  	"fmt"
    21  	"io"
    22  	"os"
    23  	"sort"
    24  	"strconv"
    25  	"strings"
    26  
    27  	gitlab "github.com/drone/go-convert/convert/gitlab/yaml"
    28  	"github.com/drone/go-convert/internal/store"
    29  	harness "github.com/drone/spec/dist/go"
    30  
    31  	"dario.cat/mergo"
    32  	"github.com/ghodss/yaml"
    33  )
    34  
    35  // conversion context
    36  type context struct {
    37  	config *gitlab.Pipeline
    38  	job    *gitlab.Job
    39  }
    40  
    41  // Converter converts a Gitlab pipeline to a Harness
    42  // v1 pipeline.
    43  type Converter struct {
    44  	kubeEnabled   bool
    45  	kubeNamespace string
    46  	kubeConnector string
    47  	dockerhubConn string
    48  	identifiers   *store.Identifiers
    49  
    50  	// config *gitlab.Pipeline
    51  	// job    *gitlab.Job
    52  }
    53  
    54  // New creates a new Converter that converts a GitLab
    55  // pipeline to a Harness v1 pipeline.
    56  func New(options ...Option) *Converter {
    57  	d := new(Converter)
    58  
    59  	// create the unique identifier store. this store
    60  	// is used for registering unique identifiers to
    61  	// prevent duplicate names, unique index violations.
    62  	d.identifiers = store.New()
    63  
    64  	// loop through and apply the options.
    65  	for _, option := range options {
    66  		option(d)
    67  	}
    68  
    69  	// set the default kubernetes namespace.
    70  	if d.kubeNamespace == "" {
    71  		d.kubeNamespace = "default"
    72  	}
    73  
    74  	// set the runtime to kubernetes if the kubernetes
    75  	// connector is configured.
    76  	if d.kubeConnector != "" {
    77  		d.kubeEnabled = true
    78  	}
    79  
    80  	return d
    81  }
    82  
    83  // Convert downgrades a v1 pipeline.
    84  func (d *Converter) Convert(r io.Reader) ([]byte, error) {
    85  	src, err := gitlab.Parse(r)
    86  	if err != nil {
    87  		return nil, err
    88  	}
    89  	return d.convert(&context{
    90  		config: src,
    91  	})
    92  }
    93  
    94  // ConvertBytes downgrades a v1 pipeline.
    95  func (d *Converter) ConvertBytes(b []byte) ([]byte, error) {
    96  	return d.Convert(
    97  		bytes.NewBuffer(b),
    98  	)
    99  }
   100  
   101  // ConvertString downgrades a v1 pipeline.
   102  func (d *Converter) ConvertString(s string) ([]byte, error) {
   103  	return d.Convert(
   104  		bytes.NewBufferString(s),
   105  	)
   106  }
   107  
   108  // ConvertFile downgrades a v1 pipeline.
   109  func (d *Converter) ConvertFile(p string) ([]byte, error) {
   110  	f, err := os.Open(p)
   111  	if err != nil {
   112  		return nil, err
   113  	}
   114  	defer f.Close()
   115  	return d.Convert(f)
   116  }
   117  
   118  // converts converts a GitLab pipeline to a Harness pipeline.
   119  func (d *Converter) convert(ctx *context) ([]byte, error) {
   120  
   121  	// create the harness pipeline spec
   122  	dst := &harness.Pipeline{}
   123  
   124  	// create the harness pipeline resource
   125  	config := &harness.Config{
   126  		Version: 1,
   127  		Kind:    "pipeline",
   128  		Spec:    dst,
   129  	}
   130  
   131  	cacheFound := false
   132  
   133  	// TODO handle includes
   134  	// src.Include
   135  
   136  	if ctx.config.Workflow != nil {
   137  		// TODO pipeline.name removed from spec
   138  		// dst.Name = ctx.config.Workflow.Name
   139  	}
   140  
   141  	// create the harness stage.
   142  	dstStage := &harness.Stage{
   143  		Name: "test",
   144  		Type: "ci",
   145  		// When: convertCond(from.Trigger),
   146  		Spec: &harness.StageCI{
   147  			// Delegate: convertNode(from.Node),
   148  			Envs: convertVariables(ctx.config.Variables),
   149  			// Platform: convertPlatform(from.Platform),
   150  			// Runtime:  convertRuntime(from),
   151  			Steps: make([]*harness.Step, 0), // Initialize the Steps slice
   152  		},
   153  	}
   154  	dst.Stages = append(dst.Stages, dstStage)
   155  
   156  	var jobKeys []string
   157  	for jobKey := range ctx.config.Jobs {
   158  		jobKeys = append(jobKeys, jobKey)
   159  	}
   160  	sort.Strings(jobKeys)
   161  
   162  	stages := ctx.config.Stages
   163  	stagesLength := len(stages)
   164  	if stagesLength == 0 {
   165  		stages = []string{".pre", "build", "test", "deploy", ".post"} // stages don't have to be declared for valid yaml. Default to test
   166  	} else {
   167  		dstStage.Name = ctx.config.Stages[0]
   168  	}
   169  
   170  	for _, stageName := range stages {
   171  		stageSteps := make([]*harness.Step, 0)
   172  
   173  		// maintain stage name if set
   174  		if stagesLength != 0 {
   175  			dstStage.Name = stageName
   176  		}
   177  
   178  		// iterate through jobs and find jobs assigned to the stage. Skip other stages.
   179  		for _, jobName := range jobKeys {
   180  			job := ctx.config.Jobs[jobName] // maintaining order here
   181  			if job.Before != nil {
   182  				job.Stage = ".pre"
   183  			}
   184  			if job.After != nil {
   185  				job.Stage = ".post"
   186  			}
   187  			if job.Stage == "" {
   188  				job.Stage = "test" // default
   189  			}
   190  			if !cacheFound && job.Cache != nil {
   191  				dstStage.Spec.(*harness.StageCI).Cache = convertCache(job.Cache) // Update cache if it's defined in the job
   192  				cacheFound = true
   193  			}
   194  
   195  			if len(job.Extends) > 0 {
   196  				for _, extend := range job.Extends {
   197  					if templateJob, ok := ctx.config.TemplateJobs[extend]; ok {
   198  						// Perform deep merge of the template job into the current job.
   199  						mergedJob, err := mergeJobConfiguration(job, templateJob)
   200  						if err != nil {
   201  							return nil, err
   202  						}
   203  						job = mergedJob
   204  					}
   205  				}
   206  			}
   207  
   208  			if job == nil || job.Stage != stageName {
   209  				continue
   210  			}
   211  
   212  			if job.Parallel != nil {
   213  				if job.Parallel.Matrix != nil {
   214  					for i, matrix := range job.Parallel.Matrix {
   215  						steps := convertJobToStep(ctx, fmt.Sprintf("%s-%d", jobName, i), job, matrix)
   216  						stageSteps = append(stageSteps, steps...)
   217  					}
   218  				}
   219  			} else {
   220  				// Convert each job to a step
   221  				steps := convertJobToStep(ctx, jobName, job, nil)
   222  				for _, step := range steps {
   223  					// Prepend the pipeline-level before_script
   224  					if ctx.config.BeforeScript != nil {
   225  						prependScript := convertScriptToStep(ctx.config.BeforeScript, "", "", false)
   226  						step.Spec.(*harness.StepExec).Run = prependScript.Spec.(*harness.StepExec).Run + "\n" + step.Spec.(*harness.StepExec).Run
   227  					}
   228  
   229  					// Prepend the job-specific before_script
   230  					if job.Before != nil {
   231  						prependScript := convertScriptToStep(job.Before, "", "", false)
   232  						step.Spec.(*harness.StepExec).Run = prependScript.Spec.(*harness.StepExec).Run + "\n" + step.Spec.(*harness.StepExec).Run
   233  					}
   234  					stageSteps = append(stageSteps, step)
   235  				}
   236  
   237  				if job.Inherit != nil && job.Inherit.Variables != nil {
   238  					dstStage.Spec.(*harness.StageCI).Envs = convertInheritedVariables(job, dstStage.Spec.(*harness.StageCI).Envs)
   239  				}
   240  			}
   241  		}
   242  		// If there are multiple steps, wrap them with a parallel group to mirror gitlab behavior
   243  		if len(stageSteps) > 1 {
   244  			group := &harness.Step{
   245  				Type: "parallel",
   246  				Spec: &harness.StepParallel{
   247  					Steps: stageSteps,
   248  				},
   249  			}
   250  			dstStage.Spec.(*harness.StageCI).Steps = append(dstStage.Spec.(*harness.StageCI).Steps, group)
   251  		} else if len(stageSteps) == 1 {
   252  			// If there's a single step, append it to the stage directly
   253  			dstStage.Spec.(*harness.StageCI).Steps = append(dstStage.Spec.(*harness.StageCI).Steps, stageSteps[0])
   254  		}
   255  	}
   256  
   257  	// marshal the harness yaml
   258  	out, err := yaml.Marshal(config)
   259  	if err != nil {
   260  		return nil, err
   261  	}
   262  
   263  	return out, nil
   264  }
   265  
   266  // convertCache converts a GitLab cache to a Harness cache.
   267  func convertCache(cache *gitlab.Cache) *harness.Cache {
   268  	if cache == nil {
   269  		return nil
   270  	}
   271  
   272  	return &harness.Cache{
   273  		Enabled: true,
   274  		Key:     cache.Key.Value,
   275  		Paths:   cache.Paths,
   276  		Policy:  cache.Policy,
   277  	}
   278  }
   279  
   280  // convertScriptToStep converts a GitLab script to a Harness step.
   281  func convertScriptToStep(script []string, name, timeout string, onFailureIgnore bool) *harness.Step {
   282  	spec := new(harness.StepExec)
   283  	spec.Run = strings.Join(script, "\n")
   284  
   285  	step := &harness.Step{
   286  		Name: name,
   287  		Type: "script",
   288  		Spec: spec,
   289  	}
   290  	if timeout != "" {
   291  		step.Timeout = timeout
   292  	}
   293  	if onFailureIgnore {
   294  		step.Failure = &harness.FailureList{
   295  			Items: []*harness.Failure{
   296  				{
   297  					Action: &harness.FailureAction{
   298  						Type: "ignore",
   299  					},
   300  				},
   301  			},
   302  		}
   303  	}
   304  
   305  	return step
   306  }
   307  
   308  // convertJobToStep converts a GitLab job to a Harness step.
   309  func convertJobToStep(ctx *context, jobName string, job *gitlab.Job, matrix map[string][]string) []*harness.Step {
   310  	var steps []*harness.Step
   311  	spec := new(harness.StepExec)
   312  
   313  	if imageProvided(job.Image) {
   314  		spec = convertImage(job.Image)
   315  	} else if useDefaultImage(job, ctx) {
   316  		spec = convertImage(ctx.config.Default.Image)
   317  	} else if imageProvided(ctx.config.Image) {
   318  		spec = convertImage(ctx.config.Image)
   319  	}
   320  
   321  	if job.Inherit == nil || job.Inherit.Default == nil || job.Inherit.Default.All {
   322  		convertInheritDefaultFields(spec, ctx.config.Default, nil)
   323  	} else {
   324  		convertInheritDefaultFields(spec, ctx.config.Default, job.Inherit.Default.Keys)
   325  	}
   326  
   327  	// Convert all scripts into a single step
   328  	script := append(job.Script)
   329  
   330  	spec.Run = strings.Join(script, "\n")
   331  
   332  	var on *harness.FailureList
   333  	if job.Retry != nil {
   334  		on = convertRetry(job)
   335  	} else if job.AllowFailure != nil {
   336  		on = convertAllowFailure(job)
   337  	}
   338  
   339  	// set step environment variables
   340  	if job.Variables != nil || job.Secrets != nil || matrix != nil {
   341  		spec.Envs = make(map[string]string)
   342  
   343  		// job variables become step variables
   344  		if job.Variables != nil {
   345  			envVariables := convertVariables(job.Variables)
   346  			for key := range envVariables {
   347  				spec.Envs[key] = envVariables[key]
   348  			}
   349  		}
   350  
   351  		// job secrets become step variables that reference Harness secrets
   352  		if job.Secrets != nil {
   353  			envSecrets := convertSecrets(job.Secrets)
   354  			for key := range envSecrets {
   355  				spec.Envs[key] = envSecrets[key]
   356  			}
   357  		}
   358  
   359  		// job matrix axes become step variables that reference Harness matrix values
   360  		if matrix != nil {
   361  			envMatrix := convertVariablesMatrix(matrix)
   362  			for key := range envMatrix {
   363  				spec.Envs[key] = envMatrix[key]
   364  			}
   365  		}
   366  	}
   367  
   368  	var strategy *harness.Strategy
   369  	if matrix != nil {
   370  		strategy = convertStrategy(matrix)
   371  	}
   372  
   373  	step := &harness.Step{
   374  		Name: jobName,
   375  		Type: "script",
   376  		Spec: spec,
   377  	}
   378  	// map on if exists
   379  	if on != nil {
   380  		step.Failure = on
   381  	}
   382  	// map strategy if exists
   383  	if strategy != nil {
   384  		step.Strategy = strategy
   385  	}
   386  
   387  	steps = append(steps, step)
   388  
   389  	// job.Cache
   390  	// job.Retry
   391  	// job.Services
   392  	// job.Timeout
   393  	// job.Tags
   394  	// job.Secrets
   395  
   396  	return steps
   397  }
   398  
   399  func imageProvided(image *gitlab.Image) bool {
   400  	return image != nil
   401  }
   402  
   403  func isInheritAll(job *gitlab.Job) bool {
   404  	return job.Inherit != nil && job.Inherit.Default != nil && job.Inherit.Default.All
   405  }
   406  
   407  func useDefaultImage(job *gitlab.Job, ctx *context) bool {
   408  	return !isInheritAll(job) && ctx.config.Default != nil && imageProvided(ctx.config.Default.Image)
   409  }
   410  
   411  // convertInheritDefaultFields converts the default fields from the default job into the current job.
   412  func convertInheritDefaultFields(spec *harness.StepExec, defaultJob *gitlab.Default, keys []string) {
   413  	if defaultJob == nil {
   414  		return
   415  	}
   416  	if keys == nil {
   417  		keys = []string{
   418  			"after_script", "before_script", "artifacts", "cache", "image",
   419  			"interruptible", "retry", "services", "tags", "duration",
   420  		}
   421  	}
   422  	for _, key := range keys {
   423  		switch key {
   424  		case "after_script":
   425  			if len(defaultJob.After) > 0 {
   426  				spec.Run = strings.Join(defaultJob.After, "\n")
   427  			}
   428  		case "before_script":
   429  			if len(defaultJob.Before) > 0 {
   430  				spec.Run = strings.Join(defaultJob.Before, "\n") + "\n" + spec.Run
   431  			}
   432  		case "artifacts":
   433  			if defaultJob.Artifacts != nil {
   434  				//TODO no supported
   435  			}
   436  		case "cache":
   437  			if defaultJob.Cache != nil {
   438  				//TODO
   439  			}
   440  		case "image":
   441  			if defaultJob.Image != nil {
   442  				spec = convertImage(defaultJob.Image)
   443  			}
   444  		case "interruptible":
   445  			//TODO not supported
   446  		case "retry":
   447  			if defaultJob.Retry != nil {
   448  				//TODO not supported
   449  			}
   450  		case "services":
   451  			if len(defaultJob.Services) > 0 {
   452  				//TODO
   453  			}
   454  		case "tags":
   455  			if len(defaultJob.Tags) > 0 {
   456  				//spec.Tags = strings.Join(defaultJob.Tags, ", ") //TODO
   457  			}
   458  		case "duration":
   459  			//spec.Timeout = defaultJob.Timeout //TODO
   460  		}
   461  	}
   462  }
   463  
   464  // convertInherit converts the inherit fields from the default job into the current job.
   465  func convertInheritedVariables(job *gitlab.Job, stageEnvs map[string]string) map[string]string {
   466  	if job.Inherit == nil || job.Inherit.Variables == nil {
   467  		return stageEnvs
   468  	}
   469  
   470  	if job.Inherit.Variables.All {
   471  		return stageEnvs
   472  	}
   473  
   474  	// If inherit.variables is an array, only the variables in the array are inherited.
   475  	if job.Inherit.Variables.Keys != nil {
   476  		newEnvs := make(map[string]string)
   477  		for _, key := range job.Inherit.Variables.Keys {
   478  			if value, ok := stageEnvs[key]; ok {
   479  				newEnvs[key] = value
   480  			}
   481  		}
   482  		return newEnvs
   483  	}
   484  
   485  	return stageEnvs
   486  }
   487  
   488  // convertImage extracts the image name, pull policy, and entrypoint from a GitLab image.
   489  func convertImage(image *gitlab.Image) *harness.StepExec {
   490  	spec := &harness.StepExec{}
   491  
   492  	if image != nil {
   493  		spec.Image = image.Name
   494  		if len(image.PullPolicy) == 1 {
   495  			pullPolicyMapping := map[string]string{
   496  				"always":         "always",
   497  				"never":          "never",
   498  				"if-not-present": "if-not-exists",
   499  			}
   500  
   501  			spec.Pull = pullPolicyMapping[image.PullPolicy[0]]
   502  		}
   503  		if len(image.Entrypoint) > 0 {
   504  			spec.Entrypoint = image.Entrypoint[0]
   505  			if len(image.Entrypoint) > 1 {
   506  				spec.Args = image.Entrypoint[1:]
   507  			}
   508  		}
   509  	}
   510  
   511  	return spec
   512  }
   513  
   514  func mergeJobConfiguration(child *gitlab.Job, parent *gitlab.Job) (*gitlab.Job, error) {
   515  	mergedJob := &gitlab.Job{}
   516  
   517  	// Copy all fields from the parent job into mergedJob.
   518  	if err := mergo.Merge(mergedJob, parent, mergo.WithOverride); err != nil {
   519  		return nil, err
   520  	}
   521  
   522  	// Then, copy all non-empty fields from the child job into mergedJob.
   523  	if err := mergo.Merge(mergedJob, child, mergo.WithOverride); err != nil {
   524  		return nil, err
   525  	}
   526  
   527  	return mergedJob, nil
   528  }
   529  
   530  // convertAllowFailure converts a GitLab job's allow_failure to a Harness step's on.failure.
   531  func convertAllowFailure(job *gitlab.Job) *harness.FailureList {
   532  	if job.AllowFailure != nil && job.AllowFailure.Value {
   533  		var exitCodesStr []string
   534  		for _, code := range job.AllowFailure.ExitCodes {
   535  			exitCodesStr = append(exitCodesStr, strconv.Itoa(code))
   536  		}
   537  		// Sort the slice to maintain order
   538  		sort.Strings(exitCodesStr)
   539  
   540  		on := &harness.FailureList{
   541  			Items: []*harness.Failure{
   542  				{
   543  					Errors: []string{"all"},
   544  					Action: &harness.FailureAction{
   545  						Type: "ignore",
   546  					},
   547  				},
   548  			},
   549  		}
   550  		if len(exitCodesStr) > 0 {
   551  			// TODO exit_code needs to be re-added to spec
   552  			// on.Failure.ExitCodes = exitCodesStr
   553  		}
   554  		return on
   555  	}
   556  	return nil
   557  }
   558  
   559  // convertVariables converts a GitLab variables map to a Harness variables map.
   560  func convertVariables(variables map[string]*gitlab.Variable) map[string]string {
   561  	result := make(map[string]string)
   562  	var keys []string
   563  	for key := range variables {
   564  		keys = append(keys, key)
   565  	}
   566  	sort.Strings(keys)
   567  
   568  	for _, key := range keys {
   569  		variable := variables[key]
   570  		if variable != nil {
   571  			result[key] = variable.Value
   572  		}
   573  	}
   574  
   575  	return result
   576  }
   577  
   578  // convertVariablesMatrix converts a matrix axis map to a Harness variables map.
   579  func convertVariablesMatrix(axis map[string][]string) map[string]string {
   580  	result := make(map[string]string)
   581  
   582  	var keys []string
   583  	for k := range axis {
   584  		keys = append(keys, k)
   585  	}
   586  	sort.Strings(keys) // to maintain order
   587  
   588  	for axisName := range axis {
   589  		result[axisName] = fmt.Sprintf("<+matrix.%s>", axisName)
   590  	}
   591  
   592  	return result
   593  }
   594  
   595  // convertRetry converts a GitLab job's retry to a Harness step's on.failure.retry.
   596  func convertRetry(job *gitlab.Job) *harness.FailureList {
   597  	if job.Retry == nil {
   598  		return nil
   599  	}
   600  
   601  	return &harness.FailureList{
   602  		Items: []*harness.Failure{
   603  			{
   604  				Action: &harness.FailureAction{
   605  					Type: "retry",
   606  					Spec: harness.Retry{
   607  						Attempts: int64(job.Retry.Max),
   608  					},
   609  				},
   610  			},
   611  		},
   612  	}
   613  }
   614  
   615  // convertSecrets converts a GitLab secrets map to a Harness secrets map.
   616  func convertSecrets(secrets map[string]*gitlab.Secret) map[string]string {
   617  	result := make(map[string]string)
   618  
   619  	var keys []string
   620  	for k := range secrets {
   621  		keys = append(keys, k)
   622  	}
   623  	sort.Strings(keys) // to maintain order
   624  
   625  	for secretName := range secrets {
   626  		result[secretName] = fmt.Sprintf("<+secrets.getValue(\"%s\")>", secretName)
   627  	}
   628  
   629  	return result
   630  }
   631  
   632  func convertStrategy(axis map[string][]string) *harness.Strategy {
   633  	return &harness.Strategy{
   634  		Type: "matrix",
   635  		Spec: &harness.Matrix{
   636  			Axis: axis,
   637  		},
   638  	}
   639  }