diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml index 6b59a8331..6523f9caf 100644 --- a/.github/workflows/pr_test.yml +++ b/.github/workflows/pr_test.yml @@ -94,6 +94,27 @@ jobs: uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + - name: "Install WASI SDK (Ubuntu)" + if: matrix.platform == 'ubuntu-latest' + run: | + wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-linux.tar.gz + tar xzf wasi-sdk-25.0-x86_64-linux.tar.gz + echo "$(pwd)/wasi-sdk-25.0-x86_64-linux/bin" >> $GITHUB_PATH + shell: bash + - name: "Install WASI SDK (macOS)" + if: matrix.platform == 'macos-latest' + run: | + wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-arm64-macos.tar.gz + tar xzf wasi-sdk-25.0-arm64-macos.tar.gz + echo "$(pwd)/wasi-sdk-25.0-arm64-macos/bin" >> $GITHUB_PATH + shell: bash + - name: "Install WASI SDK (Windows)" + if: matrix.platform == 'windows-latest' + run: | + Invoke-WebRequest -Uri "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-windows.tar.gz" -OutFile "wasi-sdk.tar.gz" + tar -xzf wasi-sdk.tar.gz + echo "$PWD/wasi-sdk-25.0-x86_64-windows/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + shell: pwsh - name: "Config Artifact" uses: actions/download-artifact@v8 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbfb0345..503378adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Enhancements: +- feat(compute): add support for cpp for compute ([#1773](https://github.com/fastly/cli/pull/1773)) + ### Dependencies: - build(deps): `golang.org/x/term` from 0.41.0 to 0.42.0 ([#1726](https://github.com/fastly/cli/pull/1726)) - build(deps): `golang.org/x/crypto` from 0.49.0 to 0.50.0 ([#1726](https://github.com/fastly/cli/pull/1726)) diff --git a/fastly-dev b/fastly-dev new file mode 100755 index 000000000..eb71746f1 Binary files /dev/null and b/fastly-dev differ diff --git a/pkg/commands/compute/build.go b/pkg/commands/compute/build.go index 6ac5a88d7..e7d163d22 100644 --- a/pkg/commands/compute/build.go +++ b/pkg/commands/compute/build.go @@ -696,6 +696,12 @@ func identifyToolchain(c *BuildCommand) (string, error) { func language(toolchain, manifestFilename string, c *BuildCommand, in io.Reader, out io.Writer, spinner text.Spinner) (*Language, error) { var language *Language switch toolchain { + case "cpp": + language = NewLanguage(&LanguageOptions{ + Name: "cpp", + SourceDirectory: CPPSourceDirectory, + Toolchain: NewCPP(c, in, manifestFilename, out, spinner), + }) case "go": language = NewLanguage(&LanguageOptions{ Name: "go", diff --git a/pkg/commands/compute/build_test.go b/pkg/commands/compute/build_test.go index f61418bbd..5903abfff 100644 --- a/pkg/commands/compute/build_test.go +++ b/pkg/commands/compute/build_test.go @@ -662,6 +662,209 @@ func TestBuildJavaScript(t *testing.T) { } } +func TestBuildCPP(t *testing.T) { + if os.Getenv("TEST_COMPUTE_BUILD_CPP") == "" && os.Getenv("TEST_COMPUTE_BUILD") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_BUILD to run this test") + } + + args := testutil.SplitArgs + + scenarios := []struct { + name string + args []string + applicationConfig *config.File + fastlyManifest string + wantError string + wantRemediationError string + wantOutput []string + }{ + { + name: "no fastly.toml manifest", + args: args("compute build"), + wantError: "error reading fastly.toml: file not found", + wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", + }, + { + name: "empty language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test"`, + wantError: "language cannot be empty, please provide a language", + }, + { + name: "unknown language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "foobar"`, + wantError: "unsupported language foobar", + }, + // The following test validates that the project compiles successfully even + // though the fastly.toml manifest has no build script. There should be a + // default build script inserted. + // + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "build script inserted dynamically when missing", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Language: config.Language{ + CPP: config.CPP{ + ToolchainConstraint: ">= 14.0.0", + WasmWasiTarget: "wasm32-wasip1", + }, + }, + }, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "cpp"`, + wantOutput: []string{ + "No [scripts.build] found in fastly.toml.", // requires --verbose + "The following default build command for C++ will be used", + "clang++ -O3 --target=wasm32-wasip1 -o ./bin/main.wasm main.cpp", + }, + }, + { + name: "build error", + args: args("compute build"), + applicationConfig: &config.File{ + Language: config.Language{ + CPP: config.CPP{ + ToolchainConstraint: ">= 14.0.0", + WasmWasiTarget: "wasm32-wasip1", + }, + }, + }, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "cpp" + + [scripts] + build = "echo no compilation happening"`, + wantRemediationError: compute.DefaultBuildErrorRemediation, + }, + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "successful build", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Language: config.Language{ + CPP: config.CPP{ + ToolchainConstraint: ">= 14.0.0", + WasmWasiTarget: "wasm32-wasip1", + }, + }, + }, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "cpp" + + [scripts] + build = "clang++ -O3 --target=wasm32-wasip1 -o bin/main.wasm main.cpp"`, + wantOutput: []string{ + "Creating ./bin directory (for Wasm binary)", + "Built package", + }, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to a build environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + wasmtoolsBinName := "wasm-tools" + + // Windows was having issues when trying to move a tmpBin file (which + // represents the latest binary downloaded from GitHub) to binPath (which + // represents the existing binary installed on a user's machine). + // + // The problem was, for the sake of the tests, I just create one file + // `wasmtoolsBinName` and used that for both `tmpBin` and `binPath` and + // this works fine on *nix systems. But once Windows did `os.Rename()` and + // move tmpBin to binPath it would no longer be able to set permissions on + // the binPath because it didn't think the file existed any more. My guess + // is that moving a file over itself causes Windows to remove the file. + // + // So to work around that issue I just create two separate files because + // in reality that's what the CLI will be dealing with. I only used one + // file for the sake of test case convenience (which ironically became + // very INCONVENIENT when the tests started unexpectedly failing on + // Windows and caused me a long time debugging). + latestDownloaded := wasmtoolsBinName + "-latest-downloaded" + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "cpp", "main.cpp"), Dst: "main.cpp"}, + }, + Write: []testutil.FileIO{ + {Src: `#!/usr/bin/env bash + echo wasm-tools 1.0.4`, Dst: wasmtoolsBinName, Executable: true}, + {Src: `#!/usr/bin/env bash + echo wasm-tools 2.0.0`, Dst: latestDownloaded, Executable: true}, + {Src: testcase.fastlyManifest, Dst: manifest.Filename}, + }, + }) + defer os.RemoveAll(rootdir) + wasmtoolsBinPath := filepath.Join(rootdir, wasmtoolsBinName) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var stdout threadsafe.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + if testcase.applicationConfig != nil { + opts.Config = *testcase.applicationConfig + } + opts.Versioners = global.Versioners{ + WasmTools: mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: wasmtoolsBinName, + DownloadOK: true, + DownloadedFile: latestDownloaded, + InstallFilePath: wasmtoolsBinPath, // avoid overwriting developer's actual wasm-tools install + }, + } + return opts, nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) + + // NOTE: Some errors we want to assert only the remediation. + // e.g. a 'stat' error isn't the same across operating systems/platforms. + if testcase.wantError != "" { + testutil.AssertErrorContains(t, err, testcase.wantError) + } + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + }) + } +} + // NOTE: TestBuildOther also validates the post_build settings. func TestBuildOther(t *testing.T) { args := testutil.SplitArgs diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index 8a35a781c..eca1a14bd 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -57,7 +57,7 @@ type InitCommand struct { } // Languages is a list of supported language options. -var Languages = []string{"rust", "javascript", "go", "other"} +var Languages = []string{"rust", "javascript", "go", "cpp", "other"} // NewInitCommand returns a usable command registered under the parent. func NewInitCommand(parent argparser.Registerer, g *global.Data) *InitCommand { diff --git a/pkg/commands/compute/language.go b/pkg/commands/compute/language.go index 9b65d2738..c39019bcb 100644 --- a/pkg/commands/compute/language.go +++ b/pkg/commands/compute/language.go @@ -37,6 +37,11 @@ func NewLanguages(kits config.StarterKitLanguages) []*Language { DisplayName: "Go", StarterKits: kits.Go, }), + NewLanguage(&LanguageOptions{ + Name: "cpp", + DisplayName: "C++", + StarterKits: kits.CPP, + }), NewLanguage(&LanguageOptions{ Name: "other", DisplayName: "Other ('bring your own' Wasm binary)", diff --git a/pkg/commands/compute/language_cpp.go b/pkg/commands/compute/language_cpp.go new file mode 100644 index 000000000..5c988107f --- /dev/null +++ b/pkg/commands/compute/language_cpp.go @@ -0,0 +1,195 @@ +package compute + +import ( + "fmt" + "io" + "os/exec" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +// CPPDefaultBuildCommand is a build command compiled into the CLI binary so it +// can be used as a fallback for customers who have an existing Compute project and +// are simply upgrading their CLI version and might not be familiar with the +// changes in the 4.0.0 release with regards to how build logic has moved to the +// fastly.toml manifest. +// +// NOTE: In the 5.x CLI releases we persisted the default to the fastly.toml +// We no longer do that. In 6.x we use the default and just inform the user. +// This makes the experience less confusing as users didn't expect file changes. +var CPPDefaultBuildCommand = fmt.Sprintf("clang++ -O3 --target=wasm32-wasip1 -o %s main.cpp", binWasmPath) + +// CPPSourceDirectory represents the source code directory. +const CPPSourceDirectory = "." + +// NewCPP constructs a new C++ toolchain. +func NewCPP( + c *BuildCommand, + in io.Reader, + manifestFilename string, + out io.Writer, + spinner text.Spinner, +) *CPP { + return &CPP{ + Shell: Shell{}, + + autoYes: c.Globals.Flags.AutoYes, + build: c.Globals.Manifest.File.Scripts.Build, + config: c.Globals.Config.Language.CPP, + env: c.Globals.Manifest.File.Scripts.EnvVars, + errlog: c.Globals.ErrLog, + input: in, + manifestFilename: manifestFilename, + metadataFilterEnvVars: c.MetadataFilterEnvVars, + nonInteractive: c.Globals.Flags.NonInteractive, + output: out, + postBuild: c.Globals.Manifest.File.Scripts.PostBuild, + spinner: spinner, + timeout: c.Flags.Timeout, + verbose: c.Globals.Verbose(), + } +} + +// CPP implements a Toolchain for the C++ language. +type CPP struct { + Shell + + // autoYes is the --auto-yes flag. + autoYes bool + // build is a shell command defined in fastly.toml using [scripts.build]. + build string + // config is the C++ specific application configuration. + config config.CPP + // defaultBuild indicates if the default build script was used. + defaultBuild bool + // env is environment variables to be set. + env []string + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // input is the user's terminal stdin stream. + input io.Reader + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // output is the user's terminal stdout stream. + output io.Writer + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// DefaultBuildScript indicates if a custom build script was used. +func (cpp *CPP) DefaultBuildScript() bool { + return cpp.defaultBuild +} + +// Dependencies returns all dependencies used by the project. +func (cpp *CPP) Dependencies() map[string]string { + // For C++, dependencies are typically managed through various systems + // (CMake, Conan, vcpkg, etc.). For now, return an empty map. + // This could be extended in the future to parse CMakeLists.txt or other files. + return make(map[string]string) +} + +// Build compiles the user's source code into a Wasm binary. +func (cpp *CPP) Build() error { + if cpp.build == "" { + cpp.build = CPPDefaultBuildCommand + cpp.defaultBuild = true + if !cpp.verbose { + text.Break(cpp.output) + } + text.Info(cpp.output, "No [scripts.build] found in %s. Visit https://www.fastly.com/documentation/guides/compute/ to learn more about building C++ projects.\n\n", cpp.manifestFilename) + text.Description(cpp.output, "The following default build command for C++ will be used", cpp.build) + } + + cpp.toolchainConstraint( + "clang++", `clang version (?P\d+\.\d+\.\d+)`, cpp.config.ToolchainConstraint, + ) + + bt := BuildToolchain{ + autoYes: cpp.autoYes, + buildFn: cpp.Shell.Build, + buildScript: cpp.build, + env: cpp.env, + errlog: cpp.errlog, + in: cpp.input, + manifestFilename: cpp.manifestFilename, + metadataFilterEnvVars: cpp.metadataFilterEnvVars, + nonInteractive: cpp.nonInteractive, + out: cpp.output, + postBuild: cpp.postBuild, + spinner: cpp.spinner, + timeout: cpp.timeout, + verbose: cpp.verbose, + } + + return bt.Build() +} + +// toolchainConstraint warns the user if the required constraint is not met. +// +// NOTE: We don't stop the build as their toolchain may compile successfully. +// The warning is to help a user know something isn't quite right and gives them +// the opportunity to do something about it if they choose. +func (cpp *CPP) toolchainConstraint(toolchain, pattern, constraint string) { + if constraint == "" { + return + } + + if cpp.verbose { + text.Info(cpp.output, "The Fastly CLI build step requires a %s version '%s'.\n\n", toolchain, constraint) + } + + versionCommand := fmt.Sprintf("%s --version", toolchain) + args := strings.Split(versionCommand, " ") + + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments + // Disabling as we trust the source of the variable. + // #nosec + // nosemgrep + cmd := exec.Command(args[0], args[1:]...) + stdoutStderr, err := cmd.CombinedOutput() + output := string(stdoutStderr) + if err != nil { + return + } + + versionPattern := regexp.MustCompile(pattern) + match := versionPattern.FindStringSubmatch(output) + if len(match) < 2 { // We expect a pattern with one capture group. + return + } + version := match[1] + + v, err := semver.NewVersion(version) + if err != nil { + return + } + + c, err := semver.NewConstraint(constraint) + if err != nil { + return + } + + valid, errs := c.Validate(v) + if !valid { + text.Warning(cpp.output, "The %s version requirement was not satisfied: %v", toolchain, errs) + } +} diff --git a/pkg/commands/compute/testdata/build/cpp/main.cpp b/pkg/commands/compute/testdata/build/cpp/main.cpp new file mode 100644 index 000000000..d2229cc06 --- /dev/null +++ b/pkg/commands/compute/testdata/build/cpp/main.cpp @@ -0,0 +1,6 @@ +#include + +int main() { + printf("Hello from C++ test!\n"); + return 0; +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 5cc43e6af..7896c0589 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -104,6 +104,7 @@ type Versioner struct { // Language represents Compute language specific configuration. type Language struct { + CPP CPP `toml:"cpp"` Go Go `toml:"go"` Rust Rust `toml:"rust"` } @@ -137,6 +138,16 @@ type Rust struct { WasmWasiTarget string `toml:"wasm_wasi_target"` } +// CPP represents C++ Compute language specific configuration. +type CPP struct { + // ToolchainConstraint is the `clang++` toolchain constraint for the compiler + // that we support (a range is expected, e.g. >= 14.0.0). + ToolchainConstraint string `toml:"toolchain_constraint"` + + // WasmWasiTarget is the C++ compilation target for Wasi capable Wasm. + WasmWasiTarget string `toml:"wasm_wasi_target"` +} + // Profiles represents multiple profile accounts. type Profiles map[string]*Profile @@ -168,6 +179,7 @@ type Profile struct { // StarterKitLanguages represents language specific starter kits. type StarterKitLanguages struct { + CPP []StarterKit `toml:"cpp"` Go []StarterKit `toml:"go"` JavaScript []StarterKit `toml:"javascript"` Rust []StarterKit `toml:"rust"`