Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .fastly/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ wasm_wasi_target = "wasm32-wasip1"
toolchain_constraint = ">= 14.0.0"
wasm_wasi_target = "wasm32-wasip1"

[language.python]
# The fastly-compute-py tool bundles Python 3.14 for the actual WASM build
toolchain_constraint = ">= 3.12"
uv_constraint = ">= 0.5.0"

[wasm-tools]
ttl = "24h"

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Enhancements:
- feat(dns): add support for DNS Zones and TSIG Keys ([#1809](https://github.com/fastly/cli/pull/1809))
- fix(compute/init): Add starter kits for C++ language [#1807](https://github.com/fastly/cli/pull/1807)
- feat(compute/init): add support for Python language [#1811](https://github.com/fastly/cli/pull/1811)

### Dependencies:
- build(deps): `github.com/bodgit/sevenzip` from 1.6.1 to 1.6.2 ([#1795](https://github.com/fastly/cli/pull/1795))
Expand Down
81 changes: 76 additions & 5 deletions pkg/commands/compute/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,15 +330,21 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l
// Allow customer to specify their own env variables to be filtered.
ExtendStaticSecretEnvVars(c.MetadataFilterEnvVars)

dc := DataCollection{}

metadata := c.Globals.Config.WasmMetadata

// Only record basic data if user has disabled all other metadata collection.
if metadata.BuildInfo == "disable" && metadata.MachineInfo == "disable" && metadata.PackageInfo == "disable" && metadata.ScriptInfo == "disable" {
return c.AnnotateWasmBinaryShort(wasmtools, args)
}

// Seed from any fastly_data already embedded by the build tool (e.g. Python).
// This lets the build tool supply package_info while the CLI fills in the
// remaining fields it is responsible for.
dc := DataCollection{}
if existing := c.readExistingFastlyData(wasmtools); existing != nil {
dc = *existing
}

if metadata.BuildInfo == "enable" {
dc.BuildInfo = DataCollectionBuildInfo{
MemoryHeapAlloc: bucketMB(bytesToMB(ms.HeapAlloc)) + "MB",
Expand All @@ -354,9 +360,11 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l
}
}
if metadata.PackageInfo == "enable" {
dc.PackageInfo = DataCollectionPackageInfo{
ClonedFrom: c.Globals.Manifest.File.ClonedFrom,
Packages: language.Dependencies(),
if dc.PackageInfo.Packages == nil {
dc.PackageInfo.Packages = language.Dependencies()
}
if dc.PackageInfo.ClonedFrom == "" {
dc.PackageInfo.ClonedFrom = c.Globals.Manifest.File.ClonedFrom
}
}
if metadata.ScriptInfo == "enable" {
Expand All @@ -377,6 +385,63 @@ func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, l
return c.Globals.ExecuteWasmTools(wasmtools, args, c.Globals)
}

// readExistingFastlyData reads any fastly_data already embedded in the Wasm
// binary by the build tool. Returns nil if absent or unparseable.
func (c *BuildCommand) readExistingFastlyData(wasmtools string) *DataCollection {
// #nosec G204 -- wasmtools path comes from trusted CLI config
out, err := exec.Command(wasmtools, "metadata", "show", "--json", binWasmPath).Output()
if err != nil {
return nil
}

// wasm-tools metadata show --json encodes producers differently for modules and components:
// - Wasm Modules: {"module":{"producers":[...]}}
// - Wasm Components: {"component":{"metadata":{"producers":[...]}}}
var meta struct {
Module struct {
Producers []json.RawMessage `json:"producers"`
} `json:"module"`
Component struct {
Metadata struct {
Producers []json.RawMessage `json:"producers"`
} `json:"metadata"`
} `json:"component"`
}
if err := json.Unmarshal(out, &meta); err != nil {
return nil
}

producers := meta.Component.Metadata.Producers
if len(producers) == 0 {
producers = meta.Module.Producers
}

for _, raw := range producers {
var pair [2]json.RawMessage
if err := json.Unmarshal(raw, &pair); err != nil {
continue
}
var field string
if err := json.Unmarshal(pair[0], &field); err != nil || field != "processed-by" {
continue
}
var entries map[string]string
if err := json.Unmarshal(pair[1], &entries); err != nil {
continue
}
val, ok := entries["fastly_data"]
if !ok {
continue
}
var dc DataCollection
if err := json.Unmarshal([]byte(val), &dc); err != nil {
return nil
}
return &dc
}
return nil
}

// ShowMetadata displays the metadata attached to the Wasm binary.
func (c *BuildCommand) ShowMetadata(wasmtools string, out io.Writer) {
// gosec flagged this:
Expand Down Expand Up @@ -715,6 +780,12 @@ func language(toolchain, manifestFilename string, c *BuildCommand, in io.Reader,
SourceDirectory: JsSourceDirectory,
Toolchain: NewJavaScript(c, in, manifestFilename, out, spinner),
})
case "python":
language = NewLanguage(&LanguageOptions{
Name: "python",
SourceDirectory: PythonSourceDirectory,
Toolchain: NewPython(c, in, manifestFilename, out, spinner),
})
case "rust":
language = NewLanguage(&LanguageOptions{
Name: "rust",
Expand Down
121 changes: 121 additions & 0 deletions pkg/commands/compute/build_metadata_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package compute

import (
"encoding/json"
"os"
"path/filepath"
"testing"
)

// mockWasmToolsScript generates a mock executable shell script for wasm-tools.
//
// Because the production code runs exec.Command under the hood, we mock it by writing
// a temporary executable bash script to disk that outputs the mock JSON we expect.
// We use a bash heredoc (cat << 'EOF') so that the JSON structure and inner quotes
// are written exactly as-is, avoiding platform-specific shell escape/echo issues.
func mockWasmToolsScript(staticOutput string) string {
return "#!/usr/bin/env bash\ncat << 'EOF'\n" + staticOutput + "\nEOF"
}

func TestReadExistingFastlyData(t *testing.T) {
// Create a temporary directory for our mock environment
rootdir, err := os.MkdirTemp("", "fastly-metadata-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(rootdir)

// Save original PWD and return to it later
pwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(rootdir); err != nil {
t.Fatal(err)
}
defer func() {
_ = os.Chdir(pwd)
}()

// Ensure the bin directory and main.wasm file exist
// (binWasmPath points to "./bin/main.wasm")
if err := os.MkdirAll("bin", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binWasmPath, []byte("mock-wasm-binary"), 0o600); err != nil {
t.Fatal(err)
}

scenarios := []struct {
name string
jsonOutput string
expectedData *DataCollection
}{
{
name: "extracts from component-based metadata structure",
jsonOutput: `{"component":{"metadata":{"producers":[["processed-by",{"fastly_data":"{\"package_info\":{\"packages\":{\"foo\":\"1.0.0\"}},\"script_info\":{\"build_script\":\"echo component\"}}"}]]}}}`,
expectedData: &DataCollection{
PackageInfo: DataCollectionPackageInfo{
Packages: map[string]string{"foo": "1.0.0"},
},
ScriptInfo: DataCollectionScriptInfo{
BuildScript: "echo component",
},
},
},
{
name: "extracts from module-based metadata structure",
jsonOutput: `{"module":{"producers":[["processed-by",{"fastly_data":"{\"package_info\":{\"packages\":{\"bar\":\"2.0.0\"}},\"script_info\":{\"build_script\":\"echo module\"}}"}]]}}`,
expectedData: &DataCollection{
PackageInfo: DataCollectionPackageInfo{
Packages: map[string]string{"bar": "2.0.0"},
},
ScriptInfo: DataCollectionScriptInfo{
BuildScript: "echo module",
},
},
},
{
name: "handles missing fastly_data gracefully",
jsonOutput: `{"component":{"metadata":{"producers":[["processed-by",{"other_tool":"1.0.0"}]]}}}`,
expectedData: nil,
},
{
name: "handles invalid JSON from wasm-tools gracefully",
jsonOutput: `invalid-json`,
expectedData: nil,
},
}

for _, tc := range scenarios {
t.Run(tc.name, func(t *testing.T) {
wasmtoolsBin := filepath.Join(rootdir, "mock-wasm-tools")
scriptContent := mockWasmToolsScript(tc.jsonOutput)
// #nosec G306 -- mock binary must be executable
if err := os.WriteFile(wasmtoolsBin, []byte(scriptContent), 0o700); err != nil {
t.Fatal(err)
}

cmd := &BuildCommand{}
actualData := cmd.readExistingFastlyData(wasmtoolsBin)

if tc.expectedData == nil {
if actualData != nil {
t.Fatalf("expected nil, got: %+v", actualData)
}
return
}

if actualData == nil {
t.Fatal("expected non-nil DataCollection, got nil")
}

// Validate values
expectedBytes, _ := json.Marshal(tc.expectedData)
actualBytes, _ := json.Marshal(actualData)
if string(expectedBytes) != string(actualBytes) {
t.Errorf("\nwant: %s\ngot: %s", expectedBytes, actualBytes)
}
})
}
}
14 changes: 13 additions & 1 deletion pkg/commands/compute/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type InitCommand struct {
}

// Languages is a list of supported language options.
var Languages = []string{"rust", "javascript", "go", "cpp", "other"}
var Languages = []string{"rust", "javascript", "go", "cpp", "python", "other"}

// NewInitCommand returns a usable command registered under the parent.
func NewInitCommand(parent argparser.Registerer, g *global.Data) *InitCommand {
Expand Down Expand Up @@ -782,6 +782,18 @@ func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader
var option string
flags := c.Globals.Flags

if len(kits) == 0 {
if flags.AcceptDefaults || flags.NonInteractive {
return "", "", "", errors.New("no default starter kits configured for this language; please specify a template using the --from flag")
}
text.Info(out, "\nNo default starter kits are currently configured for this language.")
option, err = text.Input(out, "Please paste a template git URL: ", in, nil)
if err != nil {
return "", "", "", fmt.Errorf("error reading input: %w", err)
}
return option, "", "", nil
}

if !flags.AcceptDefaults && !flags.NonInteractive {
text.Output(out, "\n%s", text.Bold("Starter kit:"))
for i, kit := range kits {
Expand Down
5 changes: 5 additions & 0 deletions pkg/commands/compute/language.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ func NewLanguages(kits config.StarterKitLanguages) []*Language {
DisplayName: "C++",
StarterKits: kits.CPP,
}),
NewLanguage(&LanguageOptions{
Name: "python",
DisplayName: "Python",
StarterKits: kits.Python,
}),
NewLanguage(&LanguageOptions{
Name: "other",
DisplayName: "Other ('bring your own' Wasm binary)",
Expand Down
Loading
Loading