From 09d5cfb263afeb06dacecbac45919587e58b05aa Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 25 Oct 2023 18:44:47 -0700 Subject: [PATCH] Patch, updates and refactor for improved `magefiles` upkeep and documentation. Summary: **Added:** - `magefiles/README.md` - New README for the `magefiles` directory detailing functions, usage examples, and contributing guidelines. - `magefiles/tmpl/README.md.tmpl` - Enhanced template for more descriptive README files for the `magefiles` package. - `docgen.go` - Extracted documentation generation logic from `magefile.go`. - `testing.go` - Segregated testing-related functions from `magefile.go`. **Changed:** - `.tool-versions` - Updated Go to version 1.21.3 and Python to version 3.12.0. - `magefiles/tmpl/README.md.tmpl` - Adjusted for better `magefiles` package documentation structure. - `magefile.go` - Split code into multiple files for easier maintenance and better organization. - Patched older net library for enhanced security and performance. Differential Revision: D50618372 --- README.md | 2 +- magefiles/docgen.go | 69 +++++ magefiles/go.mod | 8 +- magefiles/go.sum | 16 +- magefiles/magefile.go | 457 ++-------------------------------- magefiles/testing.go | 426 +++++++++++++++++++++++++++++++ magefiles/tmpl/README.md.tmpl | 7 +- 7 files changed, 532 insertions(+), 453 deletions(-) create mode 100644 magefiles/docgen.go create mode 100644 magefiles/testing.go diff --git a/README.md b/README.md index 949c24ee..324a2000 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ targets and mediums. ``` At this point, the latest `ttpforge` release should be in - `~/.local/bin/ttpforge` and subsequently, the `$USER`'s `$PATH`. + `$HOME/.local/bin/ttpforge` and subsequently, the `$USER`'s `$PATH`. If running in a stripped down system, you can add TTPForge to your `$PATH` with the following command: diff --git a/magefiles/docgen.go b/magefiles/docgen.go new file mode 100644 index 00000000..3d722632 --- /dev/null +++ b/magefiles/docgen.go @@ -0,0 +1,69 @@ +//go:build mage +// +build mage + +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package main + +import ( + "fmt" + "path/filepath" + + "github.com/l50/goutils/v2/docs" + "github.com/l50/goutils/v2/git" + "github.com/l50/goutils/v2/sys" + "github.com/spf13/afero" +) + +// GeneratePackageDocs creates documentation for the various packages +// in the project. +// +// Example usage: +// +// ```go +// mage generatepackagedocs +// ``` +// +// **Returns:** +// +// error: An error if any issue occurs during documentation generation. +func GeneratePackageDocs() error { + fs := afero.NewOsFs() + + repoRoot, err := git.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %v", err) + } + sys.Cd(repoRoot) + + repo := docs.Repo{ + Owner: "facebookincubator", + Name: "ttpforge", + } + + templatePath := filepath.Join(repoRoot, "magefiles", "tmpl", "README.md.tmpl") + // Set the packages to exclude (optional) + excludedPkgs := []string{"main"} + if err := docs.CreatePackageDocs(fs, repo, templatePath, excludedPkgs...); err != nil { + return fmt.Errorf("failed to create package docs: %v", err) + } + + return nil +} diff --git a/magefiles/go.mod b/magefiles/go.mod index d19922e3..6f846341 100755 --- a/magefiles/go.mod +++ b/magefiles/go.mod @@ -5,6 +5,7 @@ go 1.21 toolchain go1.21.2 require ( + github.com/fatih/color v1.15.0 github.com/l50/goutils/v2 v2.1.3 github.com/magefile/mage v1.15.0 github.com/spf13/afero v1.10.0 @@ -19,7 +20,6 @@ require ( github.com/cloudflare/circl v1.3.3 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fatih/color v1.15.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-git/go-git/v5 v5.9.0 // indirect @@ -35,11 +35,11 @@ require ( github.com/sergi/go-diff v1.3.1 // indirect github.com/skeema/knownhosts v1.2.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.13.0 // indirect + golang.org/x/crypto v0.14.0 // indirect golang.org/x/mod v0.12.0 // indirect - golang.org/x/net v0.15.0 // indirect + golang.org/x/net v0.17.0 // indirect golang.org/x/sync v0.3.0 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/magefiles/go.sum b/magefiles/go.sum index 3573e55a..01c11cfd 100755 --- a/magefiles/go.sum +++ b/magefiles/go.sum @@ -117,8 +117,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= @@ -130,8 +130,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -151,16 +151,16 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/magefiles/magefile.go b/magefiles/magefile.go index ecbaf94c..b60488a4 100644 --- a/magefiles/magefile.go +++ b/magefiles/magefile.go @@ -23,129 +23,19 @@ THE SOFTWARE. package main import ( - "bufio" "fmt" - "io" "os" - "path/filepath" - "runtime" - "strings" + "github.com/fatih/color" "github.com/l50/goutils/v2/dev/lint" mageutils "github.com/l50/goutils/v2/dev/mage" - "github.com/l50/goutils/v2/docs" - "github.com/l50/goutils/v2/git" "github.com/l50/goutils/v2/sys" - "github.com/spf13/afero" - - // mage utility functions - "github.com/magefile/mage/mg" - "github.com/magefile/mage/sh" ) func init() { os.Setenv("GO111MODULE", "on") } -type compileParams struct { - GOOS string - GOARCH string -} - -func (p *compileParams) populateFromEnv() { - if p.GOOS == "" { - p.GOOS = os.Getenv("GOOS") - if p.GOOS == "" { - p.GOOS = runtime.GOOS - } - } - - if p.GOARCH == "" { - p.GOARCH = os.Getenv("GOARCH") - if p.GOARCH == "" { - p.GOARCH = runtime.GOARCH - } - } -} - -// Compile compiles the Go project using goreleaser. The behavior is -// controlled by the 'release' environment variable. If the GOOS and -// GOARCH environment variables are not set, the function defaults -// to the current system's OS and architecture. -// -// **Environment Variables:** -// -// release: Determines the compilation mode. -// -// If "true", compiles all supported releases for TTPForge. -// If "false", compiles only the binary for the specified OS -// and architecture (based on GOOS and GOARCH) or the current -// system's default if the vars aren't set. -// -// GOOS: Target operating system for compilation. Defaults to the -// current system's OS if not set. -// -// GOARCH: Target architecture for compilation. Defaults to the -// current system's architecture if not set. -// -// Example usage: -// -// ```go -// release=true mage compile # Compiles all supported releases for TTPForge -// GOOS=darwin GOARCH=arm64 mage compile false # Compiles the binary for darwin/arm64 -// GOOS=linux GOARCH=amd64 mage compile false # Compiles the binary for linux/amd64 -// ``` -// -// **Returns:** -// -// error: An error if any issue occurs during compilation. -func Compile() error { - // Check for the presence of the 'release' environment variable - release, ok := os.LookupEnv("release") - if !ok { - return fmt.Errorf("'release' environment variable not set. It should be 'true' or 'false'. Example: release=true mage Compile") - } - - isRelease := false - if release == "true" { - isRelease = true - } else if release != "false" { - return fmt.Errorf("invalid value for 'release' environment variable. It should be 'true' or 'false'") - } - - if !sys.CmdExists("goreleaser") { - return fmt.Errorf("goreleaser is not installed, please run mage installdeps") - } - - cwd, err := changeToRepoRoot() - if err != nil { - return err - } - defer os.Chdir(cwd) - - doCompile := func(release bool) error { - var p compileParams - p.populateFromEnv() // Populate the GOOS and GOARCH parameters - - var args []string - - if release { - fmt.Println("Compiling all supported releases for TTPForge with goreleaser") - args = []string{"release", "--snapshot", "--clean", "--skip", "validate"} - } else { - fmt.Printf("Compiling the TTPForge binary for %s/%s, please wait.\n", p.GOOS, p.GOARCH) - args = []string{"build", "--snapshot", "--clean", "--skip", "validate", "--single-target"} - } - - if err := sh.RunV("goreleaser", args...); err != nil { - return fmt.Errorf("goreleaser failed to execute: %v", err) - } - return nil - } - - return doCompile(isRelease) -} - // InstallDeps installs the Go dependencies necessary for developing // on the project. // @@ -160,53 +50,33 @@ func Compile() error { // error: An error if any issue occurs while trying to // install the dependencies. func InstallDeps() error { - fmt.Println("Installing dependencies.") + fmt.Println(color.YellowString("Running go mod tidy in magefiles.")) + cwd := sys.Gwd() + if err := sys.Cd("magefiles"); err != nil { + return fmt.Errorf("failed to cd into magefiles directory: %v", err) + } if err := mageutils.Tidy(); err != nil { return fmt.Errorf("failed to install dependencies: %v", err) } - if err := lint.InstallGoPCDeps(); err != nil { - return fmt.Errorf("failed to install pre-commit dependencies: %v", err) + if err := sys.Cd(cwd); err != nil { + return fmt.Errorf("failed to cd back into repo root: %v", err) } - if err := mageutils.InstallVSCodeModules(); err != nil { - return fmt.Errorf("failed to install vscode-go modules: %v", err) - } - - return nil -} - -// GeneratePackageDocs creates documentation for the various packages -// in the project. -// -// Example usage: -// -// ```go -// mage generatepackagedocs -// ``` -// -// **Returns:** -// -// error: An error if any issue occurs during documentation generation. -func GeneratePackageDocs() error { - fs := afero.NewOsFs() - - repoRoot, err := git.RepoRoot() - if err != nil { - return fmt.Errorf("failed to get repo root: %v", err) + fmt.Println(color.YellowString("Running go mod tidy.")) + if err := mageutils.Tidy(); err != nil { + return fmt.Errorf("failed to install dependencies: %v", err) } - sys.Cd(repoRoot) - repo := docs.Repo{ - Owner: "facebookincubator", - Name: "ttpforge", + fmt.Println(color.YellowString("Installing go dependencies for pre-commit hooks.")) + if err := lint.InstallGoPCDeps(); err != nil { + return fmt.Errorf("failed to install pre-commit dependencies: %v", err) } - excludedPkgs := []string{"main"} - template := filepath.Join(repoRoot, "magefiles", "tmpl", "README.md.tmpl") - if err := docs.CreatePackageDocs(fs, repo, template, excludedPkgs...); err != nil { - return fmt.Errorf("failed to create package docs: %v", err) + fmt.Println(color.YellowString("Installing go dependencies required by the vscode-go extension")) + if err := mageutils.InstallVSCodeModules(); err != nil { + return fmt.Errorf("failed to install vscode-go modules: %v", err) } return nil @@ -236,307 +106,20 @@ func RunPreCommit() error { "https://github.com/facebookincubator/TTPForge/tree/main/docs/dev") } - fmt.Println("Updating pre-commit hooks.") + fmt.Println(color.YellowString("Updating pre-commit hooks.")) if err := lint.UpdatePCHooks(); err != nil { return err } - fmt.Println("Clearing the pre-commit cache to ensure we have a fresh start.") + fmt.Println(color.YellowString("Clearing the pre-commit cache to ensure we have a fresh start.")) if err := lint.ClearPCCache(); err != nil { return err } - fmt.Println("Running all pre-commit hooks locally.") + fmt.Println(color.YellowString("Running all pre-commit hooks locally.")) if err := lint.RunPCHooks(); err != nil { return err } return nil } - -// RunTests executes all unit tests. -// -// Example usage: -// -// ```go -// mage runtests -// ``` -// -// **Returns:** -// -// error: An error if any issue occurs while running the tests. -func RunTests() error { - fmt.Println("Running unit tests.") - if _, err := sys.RunCommand(filepath.Join(".hooks", "run-go-tests.sh"), "all"); err != nil { - return fmt.Errorf("failed to run unit tests: %v", err) - } - - fmt.Println("Running integration tests.") - if err := RunIntegrationTests(); err != nil { - return fmt.Errorf("failed to run integration tests: %v", err) - } - - return nil -} - -func changeToRepoRoot() (originalCwd string, err error) { - repoRoot, err := git.RepoRoot() - if err != nil { - return "", fmt.Errorf("failed to get repo root: %v", err) - } - - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("failed to get current working directory: %v", err) - } - - if cwd != repoRoot { - if err := os.Chdir(repoRoot); err != nil { - return "", fmt.Errorf("failed to change directory to repo root: %v", err) - } - } - - return cwd, nil -} - -func getBinaryDirName() (string, error) { - goos := os.Getenv("GOOS") - if goos == "" { - goos = runtime.GOOS - } - goarch := os.Getenv("GOARCH") - if goarch == "" { - goarch = runtime.GOARCH - } - baseBinaryDir := fmt.Sprintf("ttpforge_%s_%s", goos, goarch) - - dirs, err := os.ReadDir("dist") - if err != nil { - return "", err - } - - for _, dir := range dirs { - if strings.HasPrefix(dir.Name(), baseBinaryDir) { - return dir.Name(), nil - } - } - - return "", fmt.Errorf("binary directory matching pattern %s not found", baseBinaryDir) -} - -// RunIntegrationTests executes all integration tests by extracting the commands -// described in README files of TTP examples and then executing them. -// -// Example usage: -// -// ```go -// mage runintegrationtests -// ``` -// -// **Returns:** -// -// error: An error if any issue occurs while running the tests. -func RunIntegrationTests() error { - // Call Compile to generate the binary. - mg.Deps(func() error { - os.Setenv("release", "false") - return Compile() - }) - - home, err := sys.GetHomeDir() - if err != nil { - return err - } - - originalPath := os.Getenv("PATH") - - // Change to repo root and defer returning to the original directory. - cwd, err := changeToRepoRoot() - if err != nil { - return err - } - defer os.Chdir(cwd) - - binaryDirName, err := getBinaryDirName() - if err != nil { - return err - } - binDirectory := filepath.Join("dist", binaryDirName) - // Clean up the dist directory built by goreleaser. - defer os.RemoveAll(filepath.Dir(binDirectory)) - - // Get the absolute path to the binary. - repoRoot, err := git.RepoRoot() - if err != nil { - return fmt.Errorf("failed to get repo root: %v", err) - } - absoluteBinPath := filepath.Join(repoRoot, binDirectory) - - // Ensure the binary is in the expected location. - binaryPath := filepath.Join(absoluteBinPath, "ttpforge") - if _, err := os.Stat(binaryPath); os.IsNotExist(err) { - return fmt.Errorf("binary not found in expected location: %s", binaryPath) - } - - if err := os.Chmod(binaryPath, 0755); err != nil { - return fmt.Errorf("failed to set executable permissions on ttpforge binary: %v", err) - } - - // Adjust the PATH to prioritize the freshly built binary. - newPath := absoluteBinPath + string(os.PathListSeparator) + originalPath - os.Setenv("PATH", newPath) - - armoryTTPs := filepath.Join(home, ".ttpforge", "repos", "forgearmory", "ttps") - - // Parse README files to extract and run example commands, ensuring the - // validity of our examples. - return findReadmeFiles(armoryTTPs) -} - -// processLines parses an io.Reader, identifying and marking code blocks -// found in a TTP README. -func processLines(r io.Reader, language string) ([]string, error) { - scanner := bufio.NewScanner(r) - var lines, codeBlockLines []string - var inCodeBlock bool - - for scanner.Scan() { - line := scanner.Text() - - inCodeBlock, codeBlockLines = handleLineInCodeBlock(strings.TrimSpace(line), line, inCodeBlock, language, codeBlockLines) - - if !inCodeBlock { - lines = append(lines, codeBlockLines...) - codeBlockLines = codeBlockLines[:0] - if !strings.HasPrefix(line, "```") { - lines = append(lines, line) - } - } - } - - if inCodeBlock { - codeBlockLines = append(codeBlockLines, "\t\t\t// ```") - lines = append(lines, codeBlockLines...) - } - - return lines, scanner.Err() -} - -// handleLineInCodeBlock categorizes and handles each line based on its -// content and relation to code blocks found in a TTP README. -func handleLineInCodeBlock(trimmedLine, line string, inCodeBlock bool, language string, codeBlockLines []string) (bool, []string) { - switch { - case strings.HasPrefix(trimmedLine, "```"+language): - if !inCodeBlock { - codeBlockLines = append(codeBlockLines, line) - } - return !inCodeBlock, codeBlockLines - case inCodeBlock: - codeBlockLines = append(codeBlockLines, line) - case strings.Contains(trimmedLine, "```"): - inCodeBlock = false - } - return inCodeBlock, codeBlockLines -} - -// extractTTPForgeCommand extracts the TTPForge run commands from the provided -// reader (parsed README content). This approach automates the testing of -// examples by leveraging the commands documented in READMEs. -func extractTTPForgeCommand(r io.Reader) ([]string, error) { - lines, err := processLines(r, "bash") - if err != nil { - return nil, err - } - - var inCodeBlock bool - var currentCommand string - var commands []string - - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - - // Remove the backslashes at the end - trimmedLine = strings.TrimSuffix(trimmedLine, "\\") - - switch { - case strings.Contains(trimmedLine, "```bash"): - inCodeBlock = true - currentCommand = "" - case inCodeBlock && strings.Contains(trimmedLine, "```"): - inCodeBlock = false - if currentCommand != "" { - commands = append(commands, strings.TrimSpace(currentCommand)) - } - case inCodeBlock: - if currentCommand != "" { - currentCommand += " " + trimmedLine - } else { - currentCommand = trimmedLine - } - } - } - - return commands, nil -} - -// findReadmeFiles looks for README.md files in the specified directory. -// The READMEs are expected to contain TTPForge commands that serve as -// user-facing instructions for the examples. By parsing these READMEs, we can -// automatically test and validate these instructions. -func findReadmeFiles(rootDir string) error { - return filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return fmt.Errorf("error accessing path %q: %v", path, err) - } - - if !info.IsDir() && info.Name() == "README.md" && strings.Contains(path, "ttps/examples") { - return processReadme(path, info) - } - return nil - }) -} - -// processReadme reads the content of a given README file, extracts the -// TTPForge commands, and runs them. This acts as a verification step to -// ensure the examples work as described in the README. -func processReadme(path string, info os.FileInfo) error { - contents, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("error reading %s:%v", path, err) - } - - commands, err := extractTTPForgeCommand(strings.NewReader(string(contents))) - if err != nil { - return fmt.Errorf("failed to parse %v: %v", path, err) - } - - for _, command := range commands { - if err := runExtractedCommand(command, info); err != nil { - return err - } - } - return nil -} - -// runExtractedCommand executes the input TTPForge command, acting as a -// dynamic validation step. -func runExtractedCommand(command string, info os.FileInfo) error { - if command == "" { - return nil - } - - parts := strings.Fields(command) - if len(parts) < 3 { - return fmt.Errorf("unexpected command format: %s", command) - } - - mainCommand, action, ttp := parts[0], parts[1], parts[2] - args := parts[3:] - - fmt.Printf("Running command extracted from %s: %s %s %s\n\n", info.Name(), mainCommand, action, strings.Join(args, " ")) - - if _, err := sys.RunCommand(mainCommand, append([]string{action, ttp}, args...)...); err != nil { - return fmt.Errorf("failed to run command %s %s %s: %v", mainCommand, action, strings.Join(args, " "), err) - } - return nil -} diff --git a/magefiles/testing.go b/magefiles/testing.go new file mode 100644 index 00000000..dc7c0d89 --- /dev/null +++ b/magefiles/testing.go @@ -0,0 +1,426 @@ +//go:build mage +// +build mage + +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/l50/goutils/v2/git" + "github.com/l50/goutils/v2/sys" + + // mage utility functions + "github.com/magefile/mage/mg" + "github.com/magefile/mage/sh" +) + +type compileParams struct { + GOOS string + GOARCH string +} + +func (p *compileParams) populateFromEnv() { + if p.GOOS == "" { + p.GOOS = os.Getenv("GOOS") + if p.GOOS == "" { + p.GOOS = runtime.GOOS + } + } + + if p.GOARCH == "" { + p.GOARCH = os.Getenv("GOARCH") + if p.GOARCH == "" { + p.GOARCH = runtime.GOARCH + } + } +} + +// Compile compiles the Go project using goreleaser. The behavior is +// controlled by the 'release' environment variable. If the GOOS and +// GOARCH environment variables are not set, the function defaults +// to the current system's OS and architecture. +// +// **Environment Variables:** +// +// release: Determines the compilation mode. +// +// If "true", compiles all supported releases for TTPForge. +// If "false", compiles only the binary for the specified OS +// and architecture (based on GOOS and GOARCH) or the current +// system's default if the vars aren't set. +// +// GOOS: Target operating system for compilation. Defaults to the +// current system's OS if not set. +// +// GOARCH: Target architecture for compilation. Defaults to the +// current system's architecture if not set. +// +// Example usage: +// +// ```go +// release=true mage compile # Compiles all supported releases for TTPForge +// GOOS=darwin GOARCH=arm64 mage compile false # Compiles the binary for darwin/arm64 +// GOOS=linux GOARCH=amd64 mage compile false # Compiles the binary for linux/amd64 +// ``` +// +// **Returns:** +// +// error: An error if any issue occurs during compilation. +func Compile() error { + // Check for the presence of the 'release' environment variable + release, ok := os.LookupEnv("release") + if !ok { + return fmt.Errorf("'release' environment variable not set. It should be 'true' or 'false'. Example: release=true mage Compile") + } + + isRelease := false + if release == "true" { + isRelease = true + } else if release != "false" { + return fmt.Errorf("invalid value for 'release' environment variable. It should be 'true' or 'false'") + } + + if !sys.CmdExists("goreleaser") { + return fmt.Errorf("goreleaser is not installed, please run mage installdeps") + } + + cwd, err := changeToRepoRoot() + if err != nil { + return err + } + defer os.Chdir(cwd) + + doCompile := func(release bool) error { + var p compileParams + p.populateFromEnv() // Populate the GOOS and GOARCH parameters + + var args []string + + if release { + fmt.Println("Compiling all supported releases for TTPForge with goreleaser") + args = []string{"release", "--snapshot", "--clean", "--skip", "validate"} + } else { + fmt.Printf("Compiling the TTPForge binary for %s/%s, please wait.\n", p.GOOS, p.GOARCH) + args = []string{"build", "--snapshot", "--clean", "--skip", "validate", "--single-target"} + } + + if err := sh.RunV("goreleaser", args...); err != nil { + return fmt.Errorf("goreleaser failed to execute: %v", err) + } + return nil + } + + return doCompile(isRelease) +} + +// RunTests executes all unit tests. +// +// Example usage: +// +// ```go +// mage runtests +// ``` +// +// **Returns:** +// +// error: An error if any issue occurs while running the tests. +func RunTests() error { + fmt.Println("Running unit tests.") + if _, err := sys.RunCommand(filepath.Join(".hooks", "run-go-tests.sh"), "all"); err != nil { + return fmt.Errorf("failed to run unit tests: %v", err) + } + + fmt.Println("Running integration tests.") + if err := RunIntegrationTests(); err != nil { + return fmt.Errorf("failed to run integration tests: %v", err) + } + + return nil +} + +// RunIntegrationTests executes all integration tests by extracting the commands +// described in README files of TTP examples and then executing them. +// +// Example usage: +// +// ```go +// mage runintegrationtests +// ``` +// +// **Returns:** +// +// error: An error if any issue occurs while running the tests. +func RunIntegrationTests() error { + // Call Compile to generate the binary. + mg.Deps(func() error { + os.Setenv("release", "false") + return Compile() + }) + + home, err := sys.GetHomeDir() + if err != nil { + return err + } + + originalPath := os.Getenv("PATH") + + // Change to repo root and defer returning to the original directory. + cwd, err := changeToRepoRoot() + if err != nil { + return err + } + defer os.Chdir(cwd) + + binaryDirName, err := getBinaryDirName() + if err != nil { + return err + } + binDirectory := filepath.Join("dist", binaryDirName) + // Clean up the dist directory built by goreleaser. + defer os.RemoveAll(filepath.Dir(binDirectory)) + + // Get the absolute path to the binary. + repoRoot, err := git.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %v", err) + } + absoluteBinPath := filepath.Join(repoRoot, binDirectory) + + // Ensure the binary is in the expected location. + binaryPath := filepath.Join(absoluteBinPath, "ttpforge") + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + return fmt.Errorf("binary not found in expected location: %s", binaryPath) + } + + if err := os.Chmod(binaryPath, 0755); err != nil { + return fmt.Errorf("failed to set executable permissions on ttpforge binary: %v", err) + } + + // Adjust the PATH to prioritize the freshly built binary. + newPath := absoluteBinPath + string(os.PathListSeparator) + originalPath + os.Setenv("PATH", newPath) + + armoryTTPs := filepath.Join(home, ".ttpforge", "repos", "forgearmory", "ttps") + + // Parse README files to extract and run example commands, ensuring the + // validity of our examples. + return findReadmeFiles(armoryTTPs) +} + +func changeToRepoRoot() (originalCwd string, err error) { + repoRoot, err := git.RepoRoot() + if err != nil { + return "", fmt.Errorf("failed to get repo root: %v", err) + } + + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current working directory: %v", err) + } + + if cwd != repoRoot { + if err := os.Chdir(repoRoot); err != nil { + return "", fmt.Errorf("failed to change directory to repo root: %v", err) + } + } + + return cwd, nil +} + +func getBinaryDirName() (string, error) { + goos := os.Getenv("GOOS") + if goos == "" { + goos = runtime.GOOS + } + goarch := os.Getenv("GOARCH") + if goarch == "" { + goarch = runtime.GOARCH + } + baseBinaryDir := fmt.Sprintf("ttpforge_%s_%s", goos, goarch) + + dirs, err := os.ReadDir("dist") + if err != nil { + return "", err + } + + for _, dir := range dirs { + if strings.HasPrefix(dir.Name(), baseBinaryDir) { + return dir.Name(), nil + } + } + + return "", fmt.Errorf("binary directory matching pattern %s not found", baseBinaryDir) +} + +// processLines parses an io.Reader, identifying and marking code blocks +// found in a TTP README. +func processLines(r io.Reader, language string) ([]string, error) { + scanner := bufio.NewScanner(r) + var lines, codeBlockLines []string + var inCodeBlock bool + + for scanner.Scan() { + line := scanner.Text() + + inCodeBlock, codeBlockLines = handleLineInCodeBlock(strings.TrimSpace(line), line, inCodeBlock, language, codeBlockLines) + + if !inCodeBlock { + lines = append(lines, codeBlockLines...) + codeBlockLines = codeBlockLines[:0] + if !strings.HasPrefix(line, "```") { + lines = append(lines, line) + } + } + } + + if inCodeBlock { + codeBlockLines = append(codeBlockLines, "\t\t\t// ```") + lines = append(lines, codeBlockLines...) + } + + return lines, scanner.Err() +} + +// handleLineInCodeBlock categorizes and handles each line based on its +// content and relation to code blocks found in a TTP README. +func handleLineInCodeBlock(trimmedLine, line string, inCodeBlock bool, language string, codeBlockLines []string) (bool, []string) { + switch { + case strings.HasPrefix(trimmedLine, "```"+language): + if !inCodeBlock { + codeBlockLines = append(codeBlockLines, line) + } + return !inCodeBlock, codeBlockLines + case inCodeBlock: + codeBlockLines = append(codeBlockLines, line) + case strings.Contains(trimmedLine, "```"): + inCodeBlock = false + } + return inCodeBlock, codeBlockLines +} + +// extractTTPForgeCommand extracts the TTPForge run commands from the provided +// reader (parsed README content). This approach automates the testing of +// examples by leveraging the commands documented in READMEs. +func extractTTPForgeCommand(r io.Reader) ([]string, error) { + lines, err := processLines(r, "bash") + if err != nil { + return nil, err + } + + var inCodeBlock bool + var currentCommand string + var commands []string + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + + // Remove the backslashes at the end + trimmedLine = strings.TrimSuffix(trimmedLine, "\\") + + switch { + case strings.Contains(trimmedLine, "```bash"): + inCodeBlock = true + currentCommand = "" + case inCodeBlock && strings.Contains(trimmedLine, "```"): + inCodeBlock = false + if currentCommand != "" { + commands = append(commands, strings.TrimSpace(currentCommand)) + } + case inCodeBlock: + if currentCommand != "" { + currentCommand += " " + trimmedLine + } else { + currentCommand = trimmedLine + } + } + } + + return commands, nil +} + +// findReadmeFiles looks for README.md files in the specified directory. +// The READMEs are expected to contain TTPForge commands that serve as +// user-facing instructions for the examples. By parsing these READMEs, we can +// automatically test and validate these instructions. +func findReadmeFiles(rootDir string) error { + return filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error accessing path %q: %v", path, err) + } + + if !info.IsDir() && info.Name() == "README.md" && strings.Contains(path, "ttps/examples") { + return processReadme(path, info) + } + return nil + }) +} + +// processReadme reads the content of a given README file, extracts the +// TTPForge commands, and runs them. This acts as a verification step to +// ensure the examples work as described in the README. +func processReadme(path string, info os.FileInfo) error { + contents, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("error reading %s:%v", path, err) + } + + commands, err := extractTTPForgeCommand(strings.NewReader(string(contents))) + if err != nil { + return fmt.Errorf("failed to parse %v: %v", path, err) + } + + for _, command := range commands { + if err := runExtractedCommand(command, info); err != nil { + return err + } + } + return nil +} + +// runExtractedCommand executes the input TTPForge command, acting as a +// dynamic validation step. +func runExtractedCommand(command string, info os.FileInfo) error { + if command == "" { + return nil + } + + parts := strings.Fields(command) + if len(parts) < 3 { + return fmt.Errorf("unexpected command format: %s", command) + } + + mainCommand, action, ttp := parts[0], parts[1], parts[2] + args := parts[3:] + + fmt.Printf("Running command extracted from %s: %s %s %s\n\n", info.Name(), mainCommand, action, strings.Join(args, " ")) + + if _, err := sys.RunCommand(mainCommand, append([]string{action, ttp}, args...)...); err != nil { + return fmt.Errorf("failed to run command %s %s %s: %v", mainCommand, action, strings.Join(args, " "), err) + } + return nil +} diff --git a/magefiles/tmpl/README.md.tmpl b/magefiles/tmpl/README.md.tmpl index 3f02d610..e68f1a0e 100644 --- a/magefiles/tmpl/README.md.tmpl +++ b/magefiles/tmpl/README.md.tmpl @@ -1,9 +1,10 @@ # TTPForge/{{.PackageName}} -{{if ne .PackageName "magefiles"}}The `{{.PackageName}}` package is a part of the TTPForge.{{else}}`{{.PackageName}}` provides utilities that would normally be managed +{{if ne .PackageName "magefiles"}}The `{{.PackageName}}` package is a part of the TTPForge. +{{else}}`{{.PackageName}}` provides utilities that would normally be managed and executed with a `Makefile`. Instead of being written in the make language, -magefiles are crafted in Go and leverage the [Mage](https://magefile.org/) library.{{end}} - +magefiles are crafted in Go and leverage the [Mage](https://magefile.org/) library. +{{end}} --- ## Table of contents