github.com/drone/go-convert@v0.0.0-20240307072510-6bd371c65e61/convert/github/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 github converts GitHub pipelines to Harness pipelines.
    16  package github
    17  
    18  import (
    19  	"bytes"
    20  	"fmt"
    21  	"io"
    22  	"os"
    23  	"regexp"
    24  	"strconv"
    25  	"strings"
    26  	"time"
    27  
    28  	github "github.com/drone/go-convert/convert/github/yaml"
    29  	harness "github.com/drone/spec/dist/go"
    30  
    31  	"github.com/drone/go-convert/internal/store"
    32  	"github.com/ghodss/yaml"
    33  )
    34  
    35  // conversion context
    36  type context struct {
    37  	pipeline *github.Pipeline
    38  }
    39  
    40  // Converter converts a GitHub pipeline to a Harness
    41  // v1 pipeline.
    42  type Converter struct {
    43  	kubeEnabled   bool
    44  	kubeNamespace string
    45  	kubeConnector string
    46  	dockerhubConn string
    47  	identifiers   *store.Identifiers
    48  
    49  	// // as we walk the yaml, we store a
    50  	// // a snapshot of the current node and
    51  	// // its parents.
    52  	// config *github.Pipeline
    53  	// stage  *github.Stage
    54  }
    55  
    56  // New creates a new Converter that converts a GitHub
    57  // pipeline to a Harness v1 pipeline.
    58  func New(options ...Option) *Converter {
    59  	d := new(Converter)
    60  
    61  	// create the unique identifier store. this store
    62  	// is used for registering unique identifiers to
    63  	// prevent duplicate names, unique index violations.
    64  	d.identifiers = store.New()
    65  
    66  	// loop through and apply the options.
    67  	for _, option := range options {
    68  		option(d)
    69  	}
    70  
    71  	// set the default kubernetes namespace.
    72  	if d.kubeNamespace == "" {
    73  		d.kubeNamespace = "default"
    74  	}
    75  
    76  	// set the runtime to kubernetes if the kubernetes
    77  	// connector is configured.
    78  	if d.kubeConnector != "" {
    79  		d.kubeEnabled = true
    80  	}
    81  
    82  	return d
    83  }
    84  
    85  // Convert downgrades a v1 pipeline.
    86  func (d *Converter) Convert(r io.Reader) ([]byte, error) {
    87  	src, err := github.Parse(r)
    88  	if err != nil {
    89  		return nil, err
    90  	}
    91  	return d.convert(&context{
    92  		pipeline: src,
    93  	})
    94  }
    95  
    96  // ConvertBytes downgrades a v1 pipeline.
    97  func (d *Converter) ConvertBytes(b []byte) ([]byte, error) {
    98  	return d.Convert(
    99  		bytes.NewBuffer(b),
   100  	)
   101  }
   102  
   103  // ConvertString downgrades a v1 pipeline.
   104  func (d *Converter) ConvertString(s string) ([]byte, error) {
   105  	return d.Convert(
   106  		bytes.NewBufferString(s),
   107  	)
   108  }
   109  
   110  // ConvertFile downgrades a v1 pipeline.
   111  func (d *Converter) ConvertFile(p string) ([]byte, error) {
   112  	f, err := os.Open(p)
   113  	if err != nil {
   114  		return nil, err
   115  	}
   116  	defer f.Close()
   117  	return d.Convert(f)
   118  }
   119  
   120  // converts a GitHub pipeline to Harness pipeline.
   121  func (d *Converter) convert(ctx *context) ([]byte, error) {
   122  
   123  	// create the harness pipeline spec
   124  	pipeline := &harness.Pipeline{
   125  		Stages: []*harness.Stage{},
   126  	}
   127  
   128  	// create the harness pipeline resource
   129  	config := &harness.Config{
   130  		Version: 1,
   131  		Kind:    "pipeline",
   132  		Spec:    pipeline,
   133  	}
   134  
   135  	// TODO pipeline.name removed from spec
   136  	// pipeline.Name = ctx.pipeline.Name
   137  
   138  	if ctx.pipeline.Env != nil {
   139  		pipeline.Options = &harness.Default{
   140  			Envs: ctx.pipeline.Env,
   141  		}
   142  	}
   143  
   144  	//pipeline.When = convertOn(from.On) //GAP
   145  
   146  	if ctx.pipeline.Jobs != nil {
   147  		for name, job := range ctx.pipeline.Jobs {
   148  			// skip nil jobs to avoid nil-pointer
   149  			if job == nil {
   150  				continue
   151  			}
   152  
   153  			var cloneStage *harness.CloneStage
   154  			for _, step := range job.Steps {
   155  				cloneStage = convertClone(step)
   156  				if cloneStage != nil {
   157  					break
   158  				}
   159  			}
   160  
   161  			pipeline.Stages = append(pipeline.Stages, &harness.Stage{
   162  				Name:     name,
   163  				Type:     "ci",
   164  				Strategy: convertStrategy(job.Strategy),
   165  				When:     convertIf(job.If),
   166  				Spec: &harness.StageCI{
   167  					Clone:    cloneStage,
   168  					Envs:     job.Env,
   169  					Platform: convertRunsOn(job.RunsOn),
   170  					Runtime: &harness.Runtime{
   171  						Type: "cloud",
   172  						Spec: &harness.RuntimeCloud{},
   173  					},
   174  					Steps: convertSteps(job),
   175  					//Volumes:  convertVolumes(from.Volumes),
   176  
   177  					// TODO support for delegate.selectors from.Node
   178  					// TODO support for stage.variables
   179  				},
   180  			})
   181  		}
   182  	}
   183  
   184  	// marshal the harness yaml
   185  	out, err := yaml.Marshal(config)
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  
   190  	return out, nil
   191  }
   192  
   193  func convertClone(src *github.Step) *harness.CloneStage {
   194  	if src == nil || !isCheckoutAction(src.Uses) {
   195  		return nil
   196  	}
   197  	dst := new(harness.CloneStage)
   198  	if src.With != nil {
   199  		if depth, ok := src.With["fetch-depth"]; ok {
   200  			dst.Depth, _ = toInt64(depth)
   201  		}
   202  	}
   203  	return dst
   204  }
   205  
   206  func convertOn(src *github.On) *harness.When {
   207  	if src == nil || isTriggersEmpty(src) {
   208  		return nil
   209  	}
   210  
   211  	exprs := map[string]*harness.Expr{}
   212  
   213  	for eventName, eventCondition := range getEventConditions(src) {
   214  		if expr := convertEventCondition(eventCondition); expr != nil {
   215  			exprs[eventName] = expr
   216  		}
   217  	}
   218  
   219  	dst := new(harness.When)
   220  	dst.Cond = []map[string]*harness.Expr{exprs}
   221  	return dst
   222  }
   223  
   224  func convertIf(i string) *harness.When {
   225  	if i == "" {
   226  		return nil
   227  	}
   228  
   229  	i = githubExprToJexlExpr(i)
   230  
   231  	dst := new(harness.When)
   232  	dst.Eval = i
   233  	return dst
   234  }
   235  
   236  func githubExprToJexlExpr(githubExpr string) string {
   237  	// Replace functions
   238  	githubExpr = strings.Replace(githubExpr, "!contains(", "!~ ", -1)
   239  	githubExpr = strings.Replace(githubExpr, "contains(", "=~ ", -1)
   240  	githubExpr = strings.Replace(githubExpr, "startsWith(", "=^ ", -1)
   241  	githubExpr = strings.Replace(githubExpr, "endsWith(", "=$ ", -1)
   242  
   243  	// Replace variables
   244  	githubExpr = strings.Replace(githubExpr, "github.event_name", "<+trigger.event>", -1)
   245  	githubExpr = strings.Replace(githubExpr, "github.ref", "<+trigger.payload.ref>", -1)
   246  	githubExpr = strings.Replace(githubExpr, "github.head_ref", "<+trigger.sourceBranch>", -1)
   247  	githubExpr = strings.Replace(githubExpr, "github.event.ref", "<+trigger.payload.ref>", -1)
   248  	githubExpr = strings.Replace(githubExpr, "github.base_ref", "<+trigger.targetBranch>", -1)
   249  	githubExpr = strings.Replace(githubExpr, "github.event.number", "<+trigger.prNumber>", -1)
   250  	githubExpr = strings.Replace(githubExpr, "github.event.pull_request.title", "<+trigger.prTitle>", -1)
   251  	githubExpr = strings.Replace(githubExpr, "github.event.pull_request.body", "<+trigger.payload.pull_request.body>", -1)
   252  	githubExpr = strings.Replace(githubExpr, "github.event.pull_request.html_url", "<+trigger.payload.pull_request.html_url>", -1)
   253  	githubExpr = strings.Replace(githubExpr, "github.event.repository.html_url", "<+trigger.repoUrl>", -1)
   254  	githubExpr = strings.Replace(githubExpr, "github.actor", "<+trigger.gitUser>", -1)
   255  	githubExpr = strings.Replace(githubExpr, "github.actor_email", "<+codebase.gitUserEmail>", -1)
   256  
   257  	return githubExpr
   258  }
   259  
   260  func getEventConditions(src *github.On) map[string][]string {
   261  	eventConditions := make(map[string][]string)
   262  
   263  	if src.Push != nil {
   264  		eventConditions["push"] = src.Push.Branches
   265  	}
   266  	if src.PullRequest != nil {
   267  		eventConditions["pull_request"] = src.PullRequest.Branches
   268  	}
   269  	return eventConditions
   270  }
   271  
   272  func convertEventCondition(src []string) *harness.Expr {
   273  	if len(src) != 0 {
   274  		return &harness.Expr{In: src}
   275  	}
   276  	return nil
   277  }
   278  
   279  func isTriggersEmpty(src *github.On) bool {
   280  	return (src.Push == nil || len(src.Push.Branches) == 0) &&
   281  		(src.PullRequest == nil || len(src.PullRequest.Branches) == 0)
   282  }
   283  
   284  func toInt64(value interface{}) (int64, error) {
   285  	switch v := value.(type) {
   286  	case int:
   287  		return int64(v), nil
   288  	case int64:
   289  		return v, nil
   290  	case float64:
   291  		return int64(v), nil
   292  	case string:
   293  		intValue, err := strconv.Atoi(v)
   294  		return int64(intValue), err
   295  	default:
   296  		return 0, fmt.Errorf("unsupported type for conversion to int64")
   297  	}
   298  }
   299  
   300  func isCheckoutAction(action string) bool {
   301  	matched, _ := regexp.MatchString(`^actions/checkout@`, action)
   302  	return matched
   303  }
   304  
   305  func convertRunsOn(src string) *harness.Platform {
   306  	if src == "" {
   307  		return nil
   308  	}
   309  	dst := new(harness.Platform)
   310  	switch {
   311  	case strings.Contains(src, "windows"), strings.Contains(src, "win"):
   312  		dst.Os = harness.OSWindows.String()
   313  	case strings.Contains(src, "darwin"), strings.Contains(src, "macos"), strings.Contains(src, "mac"):
   314  		dst.Os = harness.OSDarwin.String()
   315  	default:
   316  		dst.Os = harness.OSLinux.String()
   317  	}
   318  	dst.Arch = harness.ArchAmd64.String() // we assume amd64 for now
   319  	return dst
   320  }
   321  
   322  // copyEnv returns a copy of the environment variable map.
   323  func copyEnv(src map[string]string) map[string]string {
   324  	dst := map[string]string{}
   325  	for k, v := range src {
   326  		dst[k] = v
   327  	}
   328  	return dst
   329  }
   330  
   331  func convertSteps(src *github.Job) []*harness.Step {
   332  	var steps []*harness.Step
   333  	for serviceName, service := range src.Services {
   334  		if service != nil {
   335  			steps = append(steps, convertServices(service, serviceName))
   336  		}
   337  	}
   338  	for _, step := range src.Steps {
   339  		if isCheckoutAction(step.Uses) {
   340  			continue
   341  		}
   342  		dst := &harness.Step{
   343  			Name: step.Name,
   344  		}
   345  
   346  		if step.ContinueOnErr {
   347  			dst.Failure = convertContinueOnError(step)
   348  		}
   349  
   350  		if step.Timeout != 0 {
   351  			dst.Timeout = convertTimeout(step)
   352  		}
   353  
   354  		if step.Uses != "" {
   355  			dst.Name = step.Name
   356  			dst.Spec = convertAction(step)
   357  			dst.Type = "action"
   358  		} else {
   359  			dst.Name = step.Name
   360  			dst.Spec = convertRun(step, src.Container)
   361  			dst.Type = "script"
   362  		}
   363  		steps = append(steps, dst)
   364  	}
   365  	return steps
   366  }
   367  
   368  func convertAction(src *github.Step) *harness.StepAction {
   369  	if src == nil {
   370  		return nil
   371  	}
   372  	dst := &harness.StepAction{
   373  		Uses: src.Uses,
   374  		With: make(map[string]interface{}),
   375  		Envs: src.Env,
   376  	}
   377  	for key, value := range src.With {
   378  		switch v := value.(type) {
   379  		case float64:
   380  			dst.With[key] = fmt.Sprintf("%v", v)
   381  		case bool:
   382  			dst.With[key] = value
   383  		case string:
   384  			if strings.HasPrefix(v, "'") && strings.HasSuffix(v, "'") {
   385  				dst.With[key] = value
   386  			} else {
   387  				dst.With[key] = fmt.Sprintf("%s", v)
   388  			}
   389  		default:
   390  			dst.With[key] = value
   391  		}
   392  	}
   393  	return dst
   394  }
   395  
   396  func convertContinueOnError(src *github.Step) *harness.FailureList {
   397  	if !src.ContinueOnErr {
   398  		return nil
   399  	}
   400  
   401  	return &harness.FailureList{
   402  		Items: []*harness.Failure{
   403  			{
   404  				Errors: []string{"all"},
   405  				Action: &harness.FailureAction{
   406  					Type: "ignore",
   407  					Spec: &harness.Ignore{},
   408  				},
   409  			},
   410  		},
   411  	}
   412  }
   413  
   414  func convertRun(src *github.Step, container *github.Container) *harness.StepExec {
   415  	if src == nil {
   416  		return nil
   417  	}
   418  	dst := &harness.StepExec{
   419  		Run:  src.Run,
   420  		Envs: src.Env,
   421  	}
   422  	if container != nil {
   423  		dst.Image = container.Image
   424  	}
   425  	return dst
   426  }
   427  
   428  func convertServices(service *github.Service, serviceName string) *harness.Step {
   429  	if service == nil {
   430  		return nil
   431  	}
   432  	return &harness.Step{
   433  		Name: serviceName,
   434  		Type: "background",
   435  		Spec: &harness.StepBackground{
   436  			Image: service.Image,
   437  			Envs:  service.Env,
   438  			Mount: convertMounts(service.Volumes),
   439  			Ports: service.Ports,
   440  			Args:  service.Options,
   441  		},
   442  	}
   443  }
   444  
   445  func convertTimeout(src *github.Step) string {
   446  	if src == nil || src.Timeout == 0 {
   447  		return "0"
   448  	}
   449  	return fmt.Sprint(time.Duration(src.Timeout * int(time.Minute)))
   450  }
   451  
   452  func convertMounts(volumes []string) []*harness.Mount {
   453  	if len(volumes) == 0 {
   454  		return nil
   455  	}
   456  	var dst []*harness.Mount
   457  
   458  	for _, volume := range volumes {
   459  		parts := strings.Split(volume, ":")
   460  
   461  		var mount harness.Mount
   462  		if len(parts) > 1 {
   463  			mount.Name = parts[0]
   464  			mount.Path = parts[1]
   465  		} else {
   466  			mount.Path = parts[0]
   467  		}
   468  
   469  		dst = append(dst, &mount)
   470  	}
   471  
   472  	return dst
   473  }
   474  
   475  func convertStrategy(src *github.Strategy) *harness.Strategy {
   476  	if src == nil || src.Matrix == nil {
   477  		return nil
   478  	}
   479  
   480  	matrix := src.Matrix
   481  
   482  	includeMaps := convertInterfaceMapsToStringMaps(matrix.Include)
   483  	excludeMaps := convertInterfaceMapsToStringMaps(matrix.Exclude)
   484  	dst := &harness.Strategy{
   485  		Type: "matrix",
   486  		Spec: &harness.Matrix{
   487  			Axis:    matrix.Matrix,
   488  			Include: includeMaps,
   489  			Exclude: excludeMaps,
   490  		},
   491  	}
   492  	return dst
   493  }
   494  
   495  func convertInterfaceMapsToStringMaps(maps []map[string]interface{}) []map[string]string {
   496  	convertedMaps := make([]map[string]string, len(maps))
   497  	for i, originalMap := range maps {
   498  		convertedMap := make(map[string]string)
   499  		for key, value := range originalMap {
   500  			convertedMap[key] = fmt.Sprintf("%v", value)
   501  		}
   502  		convertedMaps[i] = convertedMap
   503  	}
   504  	return convertedMaps
   505  }