github.com/please-build/go-rules/tools/please_go@v0.0.0-20240319165128-ea27d6f5caba/install/install.go (about)

     1  package install
     2  
     3  import (
     4  	"bufio"
     5  	"encoding/json"
     6  	"fmt"
     7  	"go/build"
     8  	"log"
     9  	"os"
    10  	"path/filepath"
    11  	"strconv"
    12  	"strings"
    13  
    14  	"github.com/please-build/go-rules/tools/please_go/embed"
    15  	"github.com/please-build/go-rules/tools/please_go/install/exec"
    16  	"github.com/please-build/go-rules/tools/please_go/install/toolchain"
    17  )
    18  
    19  const baseWorkDir = "_work"
    20  const ldFlagsFile = "LD_FLAGS"
    21  
    22  // PleaseGoInstall implements functionality similar to `go install` however it works with import configs to avoid a
    23  // dependence on the GO_PATH, go.mod or other go build concepts.
    24  type PleaseGoInstall struct {
    25  	buildContext build.Context
    26  	srcRoot      string
    27  	moduleName   string
    28  	importConfig string
    29  	outDir       string
    30  	trimPath     string
    31  
    32  	additionalCFlags string
    33  	// A set of flags we may get from: pkg-config, #cgo directives,
    34  	// go rules' `linker_flags` argument and `go.ldflags` config value.
    35  	collectedLdFlags []string
    36  
    37  	tc *toolchain.Toolchain
    38  
    39  	compiledPackages map[string]string
    40  }
    41  
    42  func (install *PleaseGoInstall) mustSetBuildContext(tags []string) {
    43  	install.buildContext = build.Default
    44  	install.buildContext.BuildTags = append(install.buildContext.BuildTags, tags...)
    45  
    46  	version, err := install.tc.GoMinorVersion()
    47  	if err != nil {
    48  		log.Fatalf("failed to determine go version: %v", err)
    49  	}
    50  
    51  	install.buildContext.ReleaseTags = []string{}
    52  	for i := 1; i <= version; i++ {
    53  		install.buildContext.ReleaseTags = append(install.buildContext.ReleaseTags, "go1."+strconv.Itoa(i))
    54  	}
    55  }
    56  
    57  // New creates a new PleaseGoInstall
    58  func New(buildTags []string, srcRoot, moduleName, importConfig, ldFlags, cFlags, goTool, ccTool, pkgConfTool, out, trimPath string) *PleaseGoInstall {
    59  	i := &PleaseGoInstall{
    60  		srcRoot:      srcRoot,
    61  		moduleName:   moduleName,
    62  		importConfig: importConfig,
    63  		outDir:       out,
    64  		trimPath:     trimPath,
    65  
    66  		additionalCFlags: cFlags,
    67  
    68  		tc: &toolchain.Toolchain{
    69  			CcTool:        ccTool,
    70  			GoTool:        goTool,
    71  			PkgConfigTool: pkgConfTool,
    72  			Exec:          &exec.Executor{Stdout: os.Stdout, Stderr: os.Stderr},
    73  		},
    74  	}
    75  	if len(ldFlags) > 0 {
    76  		i.collectedLdFlags = []string{ldFlags}
    77  	}
    78  	i.mustSetBuildContext(buildTags)
    79  	return i
    80  }
    81  
    82  // Install will compile the provided packages. Packages can be wildcards i.e. `foo/...` which compiles all packages
    83  // under the directory tree of `{module}/foo`
    84  func (install *PleaseGoInstall) Install(packages []string) error {
    85  	if err := install.initBuildEnv(); err != nil {
    86  		return err
    87  	}
    88  	if err := install.parseImportConfig(); err != nil {
    89  		return err
    90  	}
    91  
    92  	for _, target := range packages {
    93  		if !strings.HasPrefix(target, install.moduleName) {
    94  			target = filepath.Join(install.moduleName, target)
    95  		}
    96  		if strings.HasSuffix(target, "/...") {
    97  			importRoot := strings.TrimSuffix(target, "/...")
    98  			err := install.compileAll(importRoot)
    99  			if err != nil {
   100  				return err
   101  			}
   102  		} else {
   103  			if err := install.compile([]string{}, target); err != nil {
   104  				return fmt.Errorf("failed to compile %v: %w", target, err)
   105  			}
   106  
   107  			pkg, err := install.importDir(target)
   108  			if err != nil {
   109  				panic(fmt.Sprintf("import dir failed after successful compilation: %v", err))
   110  			}
   111  			if pkg.IsCommand() {
   112  				if err := install.linkPackage(target); err != nil {
   113  					return fmt.Errorf("failed to link %v: %w", target, err)
   114  				}
   115  			}
   116  		}
   117  	}
   118  
   119  	if err := install.writeLDFlags(); err != nil {
   120  		return fmt.Errorf("failed to write ld flags: %w", err)
   121  	}
   122  
   123  	return nil
   124  }
   125  
   126  func (install *PleaseGoInstall) writeLDFlags() error {
   127  	flagFile, err := os.Create(ldFlagsFile)
   128  	if err != nil {
   129  		return err
   130  	}
   131  	defer flagFile.Close()
   132  
   133  	_, err = flagFile.WriteString(strings.Join(install.collectedLdFlags, " "))
   134  	return err
   135  }
   136  
   137  func (install *PleaseGoInstall) linkPackage(target string) error {
   138  	out := install.compiledPackages[target]
   139  	filename := strings.TrimSuffix(filepath.Base(out), ".a")
   140  	binName := filepath.Join(install.outDir, "bin", filename)
   141  
   142  	return install.tc.Link(out, binName, install.importConfig, install.collectedLdFlags)
   143  }
   144  
   145  // compileAll walks the provided directory looking for go packages to compile. Unlike compile(), this will skip any
   146  // directories that contain no .go files for the current architecture.
   147  func (install *PleaseGoInstall) compileAll(dir string) error {
   148  	pkgRoot := install.pkgDir(dir)
   149  	return filepath.WalkDir(pkgRoot, func(path string, info os.DirEntry, err error) error {
   150  		if err != nil {
   151  			return err
   152  		}
   153  		if !info.IsDir() {
   154  			relativePackage := filepath.Dir(strings.TrimPrefix(path, pkgRoot))
   155  			if err := install.compile([]string{}, filepath.Join(dir, relativePackage)); err != nil {
   156  				switch err.(type) {
   157  				case *build.NoGoError:
   158  					// We might walk into a dir that has no .go files for the current arch. This shouldn't
   159  					// be an error so we just eat this
   160  					return nil
   161  				default:
   162  					return err
   163  				}
   164  			}
   165  		} else if info.Name() == "testdata" {
   166  			return filepath.SkipDir // Dirs named testdata are deemed not to contain buildable Go code.
   167  		}
   168  		return nil
   169  	})
   170  }
   171  
   172  func (install *PleaseGoInstall) initBuildEnv() error {
   173  	if err := install.tc.Exec.Run("mkdir -p %s\n", filepath.Join(install.outDir, "bin")); err != nil {
   174  		return err
   175  	}
   176  	return install.tc.Exec.Run("touch %s", ldFlagsFile)
   177  }
   178  
   179  // pkgDir returns the file path to the given target package
   180  func (install *PleaseGoInstall) pkgDir(target string) string {
   181  	p := strings.TrimPrefix(target, install.moduleName)
   182  	p = filepath.Join(install.srcRoot, p)
   183  
   184  	return p
   185  }
   186  
   187  func (install *PleaseGoInstall) parseImportConfig() error {
   188  	install.compiledPackages = map[string]string{
   189  		"unsafe": "", // Not sure how many other packages like this I need to handle
   190  		"C":      "", // Pseudo-package for cgo symbols
   191  		"embed":  "", // Another psudo package
   192  	}
   193  
   194  	if install.importConfig != "" {
   195  		f, err := os.Open(install.importConfig)
   196  		if err != nil {
   197  			return fmt.Errorf("failed to open import config: %w", err)
   198  		}
   199  		defer f.Close()
   200  
   201  		importCfg := bufio.NewScanner(f)
   202  		for importCfg.Scan() {
   203  			line := importCfg.Text()
   204  			if strings.HasPrefix(line, "#") {
   205  				continue
   206  			}
   207  			parts := strings.Split(strings.TrimPrefix(line, "packagefile "), "=")
   208  			install.compiledPackages[parts[0]] = parts[1]
   209  		}
   210  	}
   211  	return nil
   212  }
   213  
   214  func checkCycle(path []string, next string) ([]string, error) {
   215  	for i, p := range path {
   216  		if p == next {
   217  			return nil, fmt.Errorf("package cycle detected: \n%s", strings.Join(append(path[i:], next), "\n ->"))
   218  		}
   219  	}
   220  
   221  	return append(path, next), nil
   222  }
   223  
   224  func (install *PleaseGoInstall) importDir(target string) (*build.Package, error) {
   225  	dir := filepath.Join(os.Getenv("TMP_DIR"), install.pkgDir(target))
   226  	return install.buildContext.ImportDir(dir, build.ImportComment)
   227  }
   228  
   229  func (install *PleaseGoInstall) compile(from []string, target string) error {
   230  	if _, done := install.compiledPackages[target]; done {
   231  		return nil
   232  	}
   233  	fmt.Fprintf(os.Stderr, "Compiling package %s from %v\n", target, from)
   234  
   235  	from, err := checkCycle(from, target)
   236  	if err != nil {
   237  		return err
   238  	}
   239  
   240  	pkg, err := install.importDir(target)
   241  	if err != nil {
   242  		return err
   243  	}
   244  
   245  	for _, i := range pkg.Imports {
   246  		err := install.compile(from, i)
   247  		if err != nil {
   248  			if strings.Contains(err.Error(), "cannot find package") {
   249  				// Go will fail to find this import and provide a much better message than we can
   250  				continue
   251  			}
   252  			return err
   253  		}
   254  	}
   255  
   256  	err = install.compilePackage(target, pkg)
   257  	if err != nil {
   258  		return err
   259  	}
   260  	return nil
   261  }
   262  
   263  func (install *PleaseGoInstall) prepareDirectories(workDir, out string) error {
   264  	if err := install.tc.Exec.Run("mkdir -p %s", workDir); err != nil {
   265  		return err
   266  	}
   267  	return install.tc.Exec.Run("mkdir -p %s", filepath.Dir(out))
   268  }
   269  
   270  // outPath returns the path to the .a for a given package. Unlike go build, please_go install will always output to
   271  // the same location regardless of if the package matches the package dir base e.g. example.com/foo will always produce
   272  // example.com/foo/foo.a no matter what the package under there is named.
   273  //
   274  // We can get away with this because we don't compile tests so there must be exactly one package per directory.
   275  func outPath(outDir, target string) string {
   276  	dirName := filepath.Base(target)
   277  	return filepath.Join(outDir, filepath.Dir(target), dirName, dirName+".a")
   278  }
   279  
   280  func writeEmbedConfig(pkg *build.Package, path string) error {
   281  	cfg := &embed.Cfg{
   282  		Patterns: map[string][]string{},
   283  		Files:    map[string]string{},
   284  	}
   285  
   286  	if err := cfg.AddPackage(pkg); err != nil {
   287  		return err
   288  	}
   289  	data, err := json.Marshal(cfg)
   290  	if err != nil {
   291  		return err
   292  	}
   293  
   294  	return os.WriteFile(path, data, 0666)
   295  }
   296  
   297  func prefixPaths(paths []string, dir string) []string {
   298  	newPaths := make([]string, len(paths))
   299  	for i, path := range paths {
   300  		newPaths[i] = filepath.Join(dir, path)
   301  	}
   302  	return newPaths
   303  }
   304  
   305  func (install *PleaseGoInstall) compilePackage(target string, pkg *build.Package) error {
   306  	if len(pkg.GoFiles)+len(pkg.CgoFiles) == 0 {
   307  		return nil
   308  	}
   309  
   310  	out := outPath(install.outDir, target)
   311  	workDir := filepath.Join(os.Getenv("TMP_DIR"), baseWorkDir, install.pkgDir(target))
   312  
   313  	if err := install.prepareDirectories(workDir, out); err != nil {
   314  		return fmt.Errorf("failed to prepare directories for %s: %w", target, err)
   315  	}
   316  
   317  	goFiles := prefixPaths(pkg.GoFiles, pkg.Dir)
   318  	objFiles := []string{}
   319  	ldFlags := []string{}
   320  
   321  	cgoFiles := prefixPaths(pkg.CgoFiles, pkg.Dir)
   322  	if len(cgoFiles) > 0 {
   323  		cFlags := pkg.CgoCFLAGS
   324  		ldFlags = append(ldFlags, pkg.CgoLDFLAGS...)
   325  
   326  		// Collect pkg-config flags.
   327  		if len(pkg.CgoPkgConfig) > 0 {
   328  			pkgConfCFlags, err := install.tc.PkgConfigCFlags(pkg.CgoPkgConfig)
   329  			if err != nil {
   330  				return err
   331  			}
   332  
   333  			cFlags = append(cFlags, pkgConfCFlags...)
   334  
   335  			pkgConfLDFlags, err := install.tc.PkgConfigLDFlags(pkg.CgoPkgConfig)
   336  			if err != nil {
   337  				return err
   338  			}
   339  
   340  			ldFlags = append(ldFlags, pkgConfLDFlags...)
   341  			if len(pkgConfLDFlags) > 0 {
   342  				fmt.Fprintf(os.Stderr, "------ ***** ------ ld flags for %s: %s\n", target, strings.Join(pkgConfLDFlags, " "))
   343  			}
   344  		}
   345  
   346  		// Append C flags passed to the program.
   347  		if f := install.additionalCFlags; f != "" {
   348  			cFlags = append(cFlags, f)
   349  		}
   350  
   351  		cgoGoWorkFiles, cgoCWorkFiles, err := install.tc.CGO(pkg.Dir, workDir, cFlags, cgoFiles)
   352  		if err != nil {
   353  			return err
   354  		}
   355  		goFiles = append(goFiles, cgoGoWorkFiles...)
   356  
   357  		// Compile the C files generated by the GCO command above.
   358  		cgoCObjFiles, err := install.tc.CCompile(workDir, workDir, cgoCWorkFiles, append(cFlags, "-I"+pkg.Dir))
   359  		if err != nil {
   360  			return err
   361  		}
   362  		objFiles = append(objFiles, cgoCObjFiles...)
   363  
   364  		// Compile C files in original source code.
   365  		cFiles := prefixPaths(pkg.CFiles, pkg.Dir)
   366  		if len(cFiles) > 0 {
   367  			cObjFiles, err := install.tc.CCompile(pkg.Dir, workDir, cFiles, append(cFlags, "-I"+workDir))
   368  			if err != nil {
   369  				return err
   370  			}
   371  			objFiles = append(objFiles, cObjFiles...)
   372  		}
   373  
   374  		// Compile CXX files in original source code.
   375  		ccFiles := prefixPaths(pkg.CXXFiles, pkg.Dir)
   376  		if len(ccFiles) > 0 {
   377  			ccObjFiles, err := install.tc.CCompile(pkg.Dir, workDir, ccFiles, append(append(cFlags, pkg.CgoCXXFLAGS...), "-I"+workDir))
   378  			if err != nil {
   379  				return err
   380  			}
   381  			objFiles = append(objFiles, ccObjFiles...)
   382  		}
   383  	}
   384  
   385  	embedConfig := ""
   386  	if len(pkg.EmbedPatterns) > 0 {
   387  		embedConfig = filepath.Join(workDir, "embed.cfg")
   388  		if err := writeEmbedConfig(pkg, embedConfig); err != nil {
   389  			return fmt.Errorf("failed to write embed config: %v", err)
   390  		}
   391  	}
   392  
   393  	importPath := target
   394  	if pkg.IsCommand() {
   395  		importPath = "main"
   396  	}
   397  
   398  	asmFiles := prefixPaths(pkg.SFiles, pkg.Dir)
   399  	if len(asmFiles) > 0 {
   400  		asmH, symabis, err := install.tc.Symabis(importPath, pkg.Dir, workDir, asmFiles)
   401  		if err != nil {
   402  			return err
   403  		}
   404  
   405  		if err := install.tc.GoAsmCompile(importPath, install.importConfig, out, install.trimPath, embedConfig, goFiles, asmH, symabis); err != nil {
   406  			return err
   407  		}
   408  
   409  		asmObjFiles, err := install.tc.Asm(importPath, pkg.Dir, workDir, install.trimPath, asmFiles)
   410  		if err != nil {
   411  			return err
   412  		}
   413  
   414  		objFiles = append(objFiles, asmObjFiles...)
   415  	} else if err := install.tc.GoCompile(pkg.Dir, importPath, install.importConfig, out, install.trimPath, embedConfig, goFiles); err != nil {
   416  		return err
   417  	}
   418  
   419  	if len(objFiles) > 0 {
   420  		if err := install.tc.Pack(workDir, out, objFiles); err != nil {
   421  			return err
   422  		}
   423  	}
   424  
   425  	if err := install.tc.Exec.Run("echo \"packagefile %s=%s\" >> %s", target, out, install.importConfig); err != nil {
   426  		return err
   427  	}
   428  
   429  	install.collectedLdFlags = append(install.collectedLdFlags, ldFlags...)
   430  
   431  	install.compiledPackages[target] = out
   432  	return nil
   433  }