github.com/drone/go-convert@v0.0.0-20240307072510-6bd371c65e61/convert/circle/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 circle converts Circle pipelines to Harness pipelines.
    16  package circle
    17  
    18  import (
    19  	"bytes"
    20  	"errors"
    21  	"fmt"
    22  	"io"
    23  	"os"
    24  	"strings"
    25  
    26  	"github.com/drone/go-convert/convert/circle/internal/orbs"
    27  	circle "github.com/drone/go-convert/convert/circle/yaml"
    28  	harness "github.com/drone/spec/dist/go"
    29  
    30  	"github.com/drone/go-convert/internal/store"
    31  	"github.com/ghodss/yaml"
    32  )
    33  
    34  // Converter converts a Circle pipeline to a harness
    35  // v1 pipeline.
    36  type Converter struct {
    37  	kubeEnabled   bool
    38  	kubeNamespace string
    39  	kubeConnector string
    40  	gcsBucket     string
    41  	gcsToken      string
    42  	gcsEnabled    bool
    43  	s3Enabled     bool
    44  	s3Bucket      string
    45  	s3Region      string
    46  	s3AccessKey   string
    47  	s3SecretKey   string
    48  	dockerhubConn string
    49  	identifiers   *store.Identifiers
    50  }
    51  
    52  // New creates a new Converter that converts a Circle
    53  // pipeline to a harness v1 pipeline.
    54  func New(options ...Option) *Converter {
    55  	d := new(Converter)
    56  
    57  	// create the unique identifier store. this store
    58  	// is used for registering unique identifiers to
    59  	// prevent duplicate names, unique index violations.
    60  	d.identifiers = store.New()
    61  
    62  	// loop through and apply the options.
    63  	for _, option := range options {
    64  		option(d)
    65  	}
    66  
    67  	// set the default kubernetes namespace.
    68  	if d.kubeNamespace == "" {
    69  		d.kubeNamespace = "default"
    70  	}
    71  
    72  	// set the runtime to kubernetes if the kubernetes
    73  	// connector is configured.
    74  	if d.kubeConnector != "" {
    75  		d.kubeEnabled = true
    76  	}
    77  
    78  	// set the storage engine to s3 if configured.
    79  	if d.s3Bucket != "" {
    80  		d.s3Enabled = true
    81  	}
    82  
    83  	// set the storage engine to gcs if configured.
    84  	if d.gcsBucket != "" {
    85  		d.gcsEnabled = true
    86  	}
    87  
    88  	return d
    89  }
    90  
    91  // Convert downgrades a v1 pipeline.
    92  func (d *Converter) Convert(r io.Reader) ([]byte, error) {
    93  	src, err := circle.Parse(r)
    94  	if err != nil {
    95  		return nil, err
    96  	}
    97  	return d.convert(src)
    98  }
    99  
   100  // ConvertString downgrades a v1 pipeline.
   101  func (d *Converter) ConvertBytes(b []byte) ([]byte, error) {
   102  	return d.Convert(
   103  		bytes.NewBuffer(b),
   104  	)
   105  }
   106  
   107  // ConvertString downgrades a v1 pipeline.
   108  func (d *Converter) ConvertString(s string) ([]byte, error) {
   109  	return d.Convert(
   110  		bytes.NewBufferString(s),
   111  	)
   112  }
   113  
   114  // ConvertFile downgrades a v1 pipeline.
   115  func (d *Converter) ConvertFile(p string) ([]byte, error) {
   116  	f, err := os.Open(p)
   117  	if err != nil {
   118  		return nil, err
   119  	}
   120  	defer f.Close()
   121  	return d.Convert(f)
   122  }
   123  
   124  // converts converts a circle pipeline pipeline.
   125  func (d *Converter) convert(config *circle.Config) ([]byte, error) {
   126  
   127  	// create the harness pipeline spec
   128  	pipeline := &harness.Pipeline{}
   129  
   130  	// convert pipeline and job parameters to inputs
   131  	if params := extractParameters(config); len(params) != 0 {
   132  		pipeline.Inputs = convertParameters(params)
   133  	}
   134  
   135  	// require a minimum of 1 workflows
   136  	if config.Workflows == nil || len(config.Workflows.Items) == 0 {
   137  		return nil, errors.New("no workflows defined")
   138  	}
   139  
   140  	// choose the first workflow in the list, for now
   141  	var pipelines []*harness.Pipeline
   142  	for _, workflow := range config.Workflows.Items {
   143  		pipelines = append(pipelines, d.convertPipeline(workflow, config))
   144  	}
   145  
   146  	var buf bytes.Buffer
   147  	for i, pipeline := range pipelines {
   148  		// marshal the harness yaml
   149  		out, err := yaml.Marshal(&harness.Config{
   150  			Version: 1,
   151  			Kind:    "pipeline",
   152  			Spec:    pipeline,
   153  		})
   154  		if err != nil {
   155  			return nil, err
   156  		}
   157  
   158  		// replace circle parameters with harness parameters
   159  		out = replaceParams(out, params)
   160  
   161  		// write the pipeline to the buffer. if there are
   162  		// multiple yaml files they need to be separated
   163  		// by the document separator.
   164  		if i > 0 {
   165  			buf.WriteString("\n")
   166  			buf.WriteString("---")
   167  			buf.WriteString("\n")
   168  		}
   169  		buf.Write(out)
   170  	}
   171  
   172  	return buf.Bytes(), nil
   173  }
   174  
   175  // converts converts a circle pipeline pipeline.
   176  func (d *Converter) convertPipeline(workflow *circle.Workflow, config *circle.Config) *harness.Pipeline {
   177  
   178  	// create the harness pipeline spec
   179  	pipeline := &harness.Pipeline{}
   180  
   181  	// convert pipeline and job parameters to inputs
   182  	if params := extractParameters(config); len(params) != 0 {
   183  		pipeline.Inputs = convertParameters(params)
   184  	}
   185  
   186  	// loop through workflow jobs and convert each
   187  	// job to a stage.
   188  	for _, workflowjob := range workflow.Jobs {
   189  		// snapshot the config
   190  		config_ := config
   191  
   192  		// lookup the named job
   193  		job, ok := config_.Jobs[workflowjob.Name]
   194  		if !ok {
   195  			// if the job does not exist, check to
   196  			// see if the job is an orb.
   197  			alias, command := splitOrb(workflowjob.Name)
   198  
   199  			// lookup the orb and silently skip the
   200  			// job if not found
   201  			orb, ok := config_.Orbs[alias]
   202  			if !ok {
   203  				continue
   204  			}
   205  
   206  			// HACK (bradrydzewski) this is a temporary
   207  			// hack to create the configuration for an
   208  			// orb referenced directly in the workflow.
   209  			if orb.Inline == nil {
   210  				// config_ = new(circle.Config)
   211  				// config_.Orbs = map[string]*circle.Orb{
   212  				// 	orb.Name: {},
   213  				// }
   214  				job = &circle.Job{
   215  					Steps: []*circle.Step{
   216  						{
   217  							Custom: &circle.Custom{
   218  								Name:   workflowjob.Name,
   219  								Params: workflowjob.Params,
   220  							},
   221  						},
   222  					},
   223  				}
   224  			} else {
   225  				// lookup the orb command and silently skip
   226  				// the job if not found
   227  				job, ok = orb.Inline.Jobs[command]
   228  				if !ok {
   229  					continue
   230  				}
   231  
   232  				// replace the config_ with the orb
   233  				config_ = orb.Inline
   234  			}
   235  		}
   236  
   237  		// this section replaces circle matrix expressions
   238  		// with harness circle matrix expressions.
   239  		//
   240  		// before: << parameters.foo >>
   241  		// after: << matrix.foo >>
   242  		replaceParamsMatrix(job, workflowjob.Matrix)
   243  
   244  		// convert the circle job to a stage and silently
   245  		// skip any stages that cannot be converted.
   246  		stage := d.convertStage(job, config_)
   247  		if stage == nil {
   248  			continue
   249  		}
   250  
   251  		stage.Name = workflowjob.Name
   252  
   253  		if v := workflowjob.Matrix; v != nil {
   254  			stage.Strategy = convertMatrix(job, v)
   255  		}
   256  
   257  		// TODO workflows.[*].triggers
   258  		// TODO workflows.[*].unless
   259  		// TODO workflows.[*].when
   260  		// TODO workflows.[*].jobs[*].context
   261  		// TODO workflows.[*].jobs[*].filters
   262  		// TODO workflows.[*].jobs[*].type
   263  		// TODO workflows.[*].jobs[*].requires
   264  
   265  		// append the converted stage to the pipeline.
   266  		pipeline.Stages = append(pipeline.Stages, stage)
   267  	}
   268  
   269  	return pipeline
   270  }
   271  
   272  // helper function converts Circle job to a Harness stage.
   273  func (d *Converter) convertStage(job *circle.Job, config *circle.Config) *harness.Stage {
   274  
   275  	// create stage spec
   276  	spec := &harness.StageCI{
   277  		Envs:     job.Environment,
   278  		Platform: convertPlatform(job, config),
   279  		Runtime:  convertRuntime(job, config),
   280  		Steps: append(
   281  			defaultBackgroundSteps(job, config),
   282  			d.convertSteps(job.Steps, job, config)...,
   283  		),
   284  	}
   285  
   286  	// TODO executor.machine
   287  	// TODO executor.shell
   288  	// TODO executor.working_directory
   289  
   290  	// if there are no steps in the stage we
   291  	// can skip adding the stage to the pipeline.
   292  	if len(spec.Steps) == 0 {
   293  		return nil
   294  	}
   295  
   296  	// TODO job.branches
   297  	// TODO job.parallelism
   298  	// TODO job.parameters
   299  
   300  	optimizeCache(spec)
   301  	optimizeGroup(spec)
   302  
   303  	// create the stage
   304  	stage := &harness.Stage{}
   305  	stage.Type = "ci"
   306  	stage.Spec = spec
   307  	return stage
   308  }
   309  
   310  // helper function converts Circle steps to Harness steps.
   311  func (d *Converter) convertSteps(steps []*circle.Step, job *circle.Job, config *circle.Config) []*harness.Step {
   312  	var out []*harness.Step
   313  	for _, src := range steps {
   314  		if dst := d.convertStep(src, job, config); dst != nil {
   315  			out = append(out, dst)
   316  		}
   317  	}
   318  	return out
   319  }
   320  
   321  // helper function converts a Circle step to a Harness step.
   322  func (d *Converter) convertStep(step *circle.Step, job *circle.Job, config *circle.Config) *harness.Step {
   323  	switch {
   324  	case step.AddSSHKeys != nil:
   325  		return d.convertAddSSHKeys(step)
   326  	case step.AttachWorkspace != nil:
   327  		return nil // not supported
   328  	case step.Checkout != nil:
   329  		return nil // ignore
   330  	case step.PersistToWorkspace != nil:
   331  		return nil // not supported
   332  	case step.RestoreCache != nil:
   333  		return d.convertRestoreCache(step)
   334  	case step.Run != nil:
   335  		return d.convertRun(step, job, config)
   336  	case step.SaveCache != nil:
   337  		return d.convertSaveCache(step)
   338  	case step.SetupRemoteDocker != nil:
   339  		return nil // not supported
   340  	case step.StoreArtifacts != nil:
   341  		return d.convertStoreArtifacts(step)
   342  	case step.StoreTestResults != nil:
   343  		return d.convertStoreTestResults(step)
   344  	case step.Unless != nil:
   345  		return d.convertUnlessStep(step, job, config)
   346  	case step.When != nil:
   347  		return d.convertWhenStep(step, job, config)
   348  	case step.Custom != nil:
   349  		return d.convertCustom(step, job, config)
   350  	default:
   351  		return nil
   352  	}
   353  }
   354  
   355  //
   356  // Step Types
   357  //
   358  
   359  // helper function converts a Circle Run step.
   360  func (d *Converter) convertRun(step *circle.Step, job *circle.Job, config *circle.Config) *harness.Step {
   361  	// TODO run.shell
   362  	// TODO run.when
   363  	// TODO run.working_directory
   364  	// TODO docker.auth.username
   365  	// TODO docker.auth.password
   366  	// TODO docker.aws_auth.aws_access_key_id
   367  	// TODO docker.aws_auth.aws_secret_access_key
   368  
   369  	var image string
   370  	var entrypoint string
   371  	var args []string
   372  	var user string
   373  	var envs map[string]string
   374  	var shell string
   375  
   376  	if docker := extractDocker(job, config); docker != nil {
   377  		image = docker.Image
   378  		entrypoint = "" // TODO needs a Harness v1 spec change
   379  		args = docker.Command
   380  		user = docker.User
   381  		envs = docker.Environment
   382  	}
   383  
   384  	runCommand := step.Run.Command
   385  	if job.Shell != "" {
   386  		shellOptions := strings.Split(job.Shell, " ")[1:] // split the shell options from the shell binary
   387  		if len(shellOptions) > 0 {
   388  			shellOptionStr := strings.Join(shellOptions, " ")                  // join the shell options back into a single string
   389  			runCommand = fmt.Sprintf("set %s\n%s", shellOptionStr, runCommand) // prepend the shell options to the run command
   390  		}
   391  		shell = strings.Split(job.Shell, " ")[0]
   392  		shell = strings.Split(shell, "/")[len(strings.Split(shell, "/"))-1]
   393  	} else { // default shell
   394  		shell = "bash"
   395  		runCommand = "set -eo pipefail\n" + runCommand
   396  	}
   397  
   398  	if step.Run.Background {
   399  		return &harness.Step{
   400  			Name: step.Run.Name,
   401  			Type: "background",
   402  			Spec: &harness.StepBackground{
   403  				Run:        runCommand,
   404  				Envs:       combineEnvs(step.Run.Environment, envs),
   405  				Image:      image,
   406  				Entrypoint: entrypoint,
   407  				Args:       args,
   408  				User:       user,
   409  				Shell:      shell,
   410  			},
   411  		}
   412  	} else {
   413  		return &harness.Step{
   414  			Name: step.Run.Name,
   415  			Type: "script",
   416  			Spec: &harness.StepExec{
   417  				Run:        runCommand,
   418  				Envs:       combineEnvs(step.Run.Environment, envs),
   419  				Image:      image,
   420  				Entrypoint: entrypoint,
   421  				Args:       args,
   422  				User:       user,
   423  				Shell:      shell,
   424  			},
   425  		}
   426  	}
   427  }
   428  
   429  // helper function converts a Circle Restore Cache step.
   430  func (d *Converter) convertRestoreCache(step *circle.Step) *harness.Step {
   431  	// TODO support restore_cache.keys (plural)
   432  	return &harness.Step{
   433  		Name: d.identifiers.Generate(step.RestoreCache.Name, "restore_cache"),
   434  		Type: "plugin",
   435  		Spec: &harness.StepPlugin{
   436  			Image: "plugins/cache",
   437  			With: map[string]interface{}{
   438  				"bucket":                          `<+ secrets.getValue("aws_bucket") >`,
   439  				"region":                          `<+ secrets.getValue("aws_region") >`,
   440  				"access_key":                      `<+ secrets.getValue("aws_access_key_id") >`,
   441  				"secret_key":                      `<+ secrets.getValue("aws_secret_access_key") >`,
   442  				"cache_key":                       step.RestoreCache.Key,
   443  				"restore":                         "true",
   444  				"exit_code":                       "true",
   445  				"archive_format":                  "tar",
   446  				"backend":                         "s3",
   447  				"backend_operation_timeout":       "1800s",
   448  				"fail_restore_if_key_not_present": "false",
   449  			},
   450  		},
   451  	}
   452  }
   453  
   454  // helper function converts a Save Cache step.
   455  func (d *Converter) convertSaveCache(step *circle.Step) *harness.Step {
   456  	// TODO support save_cache.when
   457  	return &harness.Step{
   458  		Name: d.identifiers.Generate(step.SaveCache.Name, "save_cache"),
   459  		Type: "plugin",
   460  		Spec: &harness.StepPlugin{
   461  			Image: "plugins/cache",
   462  			With: map[string]interface{}{
   463  				"bucket":                          `<+ secrets.getValue("aws_bucket") >`,
   464  				"region":                          `<+ secrets.getValue("aws_region") >`,
   465  				"access_key":                      `<+ secrets.getValue("aws_access_key_id") >`,
   466  				"secret_key":                      `<+ secrets.getValue("aws_secret_access_key") >`,
   467  				"cache_key":                       step.SaveCache.Key,
   468  				"rebuild":                         "true",
   469  				"mount":                           step.SaveCache.Paths,
   470  				"exit_code":                       "true",
   471  				"archive_format":                  "tar",
   472  				"backend":                         "s3",
   473  				"backend_operation_timeout":       "1800s",
   474  				"fail_restore_if_key_not_present": "false",
   475  			},
   476  		},
   477  	}
   478  }
   479  
   480  // helper function converts a Add SSH Keys step.
   481  func (d *Converter) convertAddSSHKeys(step *circle.Step) *harness.Step {
   482  	// TODO step.AddSSHKeys.Fingerprints
   483  	return &harness.Step{
   484  		Name: d.identifiers.Generate(step.AddSSHKeys.Name, "add_ssh_keys"),
   485  		Type: "script",
   486  		Spec: &harness.StepExec{
   487  			Run: "echo unable to convert add_ssh_keys step",
   488  		},
   489  	}
   490  }
   491  
   492  // helper function converts a Store Artifacts step.
   493  func (d *Converter) convertStoreArtifacts(step *circle.Step) *harness.Step {
   494  	src := step.StoreArtifacts.Path
   495  	dst := step.StoreArtifacts.Destination
   496  	if dst == "" {
   497  		dst = "/"
   498  	}
   499  	return &harness.Step{
   500  		Name: d.identifiers.Generate(step.StoreArtifacts.Name, "store_artifacts"),
   501  		Type: "plugin",
   502  		Spec: &harness.StepPlugin{
   503  			Image: "plugins/s3",
   504  			With: map[string]interface{}{
   505  				"bucket":     `<+ secrets.getValue("aws_bucket") >`,
   506  				"region":     `<+ secrets.getValue("aws_region") >`,
   507  				"access_key": `<+ secrets.getValue("aws_access_key_id") >`,
   508  				"secret_key": `<+ secrets.getValue("aws_secret_access_key") >`,
   509  				"source":     src,
   510  				"target":     dst,
   511  			},
   512  		},
   513  	}
   514  }
   515  
   516  // helper function converts a Test Results step.
   517  func (d *Converter) convertStoreTestResults(step *circle.Step) *harness.Step {
   518  	return &harness.Step{
   519  		Name: d.identifiers.Generate(step.StoreTestResults.Name, "store_test_results"),
   520  		Type: "script",
   521  		Spec: &harness.StepExec{
   522  			Run: "echo upload unit test results",
   523  			Reports: []*harness.Report{
   524  				{
   525  					Path: []string{step.StoreTestResults.Path},
   526  					Type: "junit",
   527  				},
   528  			},
   529  		},
   530  	}
   531  }
   532  
   533  // helper function converts a When step.
   534  func (d *Converter) convertWhenStep(step *circle.Step, job *circle.Job, config *circle.Config) *harness.Step {
   535  	steps := d.convertSteps(step.When.Steps, job, config)
   536  	if len(steps) == 0 {
   537  		return nil
   538  	}
   539  	// TODO step.When.Condition
   540  	return &harness.Step{
   541  		Type: "group",
   542  		Spec: &harness.StepGroup{
   543  			Steps: steps,
   544  		},
   545  	}
   546  }
   547  
   548  // helper function converts an Unless step.
   549  func (d *Converter) convertUnlessStep(step *circle.Step, job *circle.Job, config *circle.Config) *harness.Step {
   550  	steps := d.convertSteps(step.Unless.Steps, job, config)
   551  	if len(steps) == 0 {
   552  		return nil
   553  	}
   554  	// TODO step.Unless.Condition
   555  	return &harness.Step{
   556  		Type: "group",
   557  		Spec: &harness.StepGroup{
   558  			Steps: steps,
   559  		},
   560  	}
   561  }
   562  
   563  // helper function converts a Custom step.
   564  func (d *Converter) convertCustom(step *circle.Step, job *circle.Job, config *circle.Config) *harness.Step {
   565  	// check to see if the step is a re-usable command.
   566  	if _, ok := config.Commands[step.Custom.Name]; ok {
   567  		return d.convertCommand(step, job, config)
   568  	}
   569  	// else convert the orb
   570  	return d.convertOrb(step, job, config)
   571  }
   572  
   573  // helper function converts a Command step.
   574  func (d *Converter) convertCommand(step *circle.Step, job *circle.Job, config *circle.Config) *harness.Step {
   575  	// extract the command
   576  	command, ok := config.Commands[step.Custom.Name]
   577  	if !ok {
   578  		return nil
   579  	}
   580  
   581  	// find and replace parameters
   582  	// https://circleci.com/docs/reusing-config/#using-the-parameters-declaration
   583  	expandParamsCommand(command, step)
   584  
   585  	// convert the circle steps to harness steps
   586  	steps := d.convertSteps(command.Steps, job, config)
   587  	if len(steps) == 0 {
   588  		return nil
   589  	}
   590  
   591  	// If there is only one step, return it directly instead of creating a group
   592  	if len(steps) == 1 {
   593  		return steps[0]
   594  	}
   595  
   596  	// return a step group
   597  	return &harness.Step{
   598  		Type: "group",
   599  		Spec: &harness.StepGroup{
   600  			Steps: steps,
   601  		},
   602  	}
   603  }
   604  
   605  // helper function converts an Orb step.
   606  func (d *Converter) convertOrb(step *circle.Step, job *circle.Job, config *circle.Config) *harness.Step {
   607  	// get the orb alias and command
   608  	alias, command := splitOrb(step.Custom.Name)
   609  
   610  	// get the orb from the configuration
   611  	orb, ok := config.Orbs[alias]
   612  	if !ok {
   613  		return nil
   614  	}
   615  
   616  	// convert inline orbs
   617  	if orb.Inline != nil {
   618  		// use the command to get the job name
   619  		// if the action does not exist, silently
   620  		// ignore the orb.
   621  		job, ok := orb.Inline.Jobs[command]
   622  		if !ok {
   623  			return nil
   624  		}
   625  		// convert the orb steps to harness steps
   626  		// if not steps are returned, silently ignore
   627  		// the orb.
   628  		steps := d.convertSteps(job.Steps, job, orb.Inline)
   629  		if len(steps) == 0 {
   630  			return nil
   631  		}
   632  		// return a step group
   633  		return &harness.Step{
   634  			Type: "group",
   635  			Spec: &harness.StepGroup{
   636  				Steps: steps,
   637  			},
   638  		}
   639  	}
   640  
   641  	name, version := splitOrbVersion(orb.Name)
   642  
   643  	// convert the orb
   644  	out := orbs.Convert(name, command, version, step.Custom)
   645  	if out != nil {
   646  		return out
   647  	}
   648  
   649  	return &harness.Step{
   650  		Name: d.identifiers.Generate(name),
   651  		Type: "script",
   652  		Spec: &harness.StepExec{
   653  			Run: fmt.Sprintf("echo unable to convert orb %s/%s", name, command),
   654  		},
   655  	}
   656  }