From 2078f91a2b749b9dadadea21c16215ad638f925a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:01:24 +0200 Subject: [PATCH] feat(01-01): generate and build hello app from manifest - Add summer build with validated summer.yaml codegen - Generate stable main.go and plugins.gen.go then go build - Smoke-test the built hello binary greeter:hello command --- cmd/summer/main.go | 33 ++++ cmd/summer/main_test.go | 41 +++++ examples/hello/hello_test.go | 46 ++++++ internal/build/build.go | 308 +++++++++++++++++++++++++++++++++++ internal/build/build_test.go | 129 +++++++++++++++ 5 files changed, 557 insertions(+) create mode 100644 cmd/summer/main.go create mode 100644 cmd/summer/main_test.go create mode 100644 internal/build/build.go create mode 100644 internal/build/build_test.go diff --git a/cmd/summer/main.go b/cmd/summer/main.go new file mode 100644 index 0000000..620be23 --- /dev/null +++ b/cmd/summer/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "fmt" + "os" + + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/internal/build" +) + +func main() { + root := bonfire.NewRoot("summer", []bonfire.Command{buildCommand()}, os.Stdout) + root.SetArgs(os.Args[1:]) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func buildCommand() bonfire.Command { + return bonfire.Command{ + Name: "build", + Description: "Generate plugin imports and build the app binary", + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + dir, err := os.Getwd() + if err != nil { + return err + } + return build.App(ctx, dir, out) + }, + } +} diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go new file mode 100644 index 0000000..24f9372 --- /dev/null +++ b/cmd/summer/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestToolDoesNotImportExamplePlugins(t *testing.T) { + roots := []string{".", filepath.Join("..", "..", "internal", "build")} + fset := token.NewFileSet() + for _, root := range roots { + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(root, name), nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatal(err) + } + if strings.Contains(path, "examples/hello") { + t.Fatalf("%s imports %s", name, path) + } + } + } + } +} diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index c59d1d6..cf694dd 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -2,6 +2,9 @@ package main import ( "bytes" + "crypto/sha256" + "os" + "os/exec" "strings" "testing" ) @@ -15,3 +18,46 @@ func TestGreeterHelloPrintsAppName(t *testing.T) { t.Fatalf("output = %q, want %q", got, "hello-app") } } + +func TestBuiltBinaryGreeterHello(t *testing.T) { + first := runSummerBuild(t) + h1 := generatedHash(t) + second := runSummerBuild(t) + h2 := generatedHash(t) + if h1 != h2 { + t.Fatalf("generated files changed on second build\nfirst:\n%s\nsecond:\n%s", first, second) + } + + cmd := exec.Command("./bin/hello", "greeter:hello") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out) + } + if got := strings.TrimSpace(string(out)); got != "hello-app" { + t.Fatalf("output = %q, want %q", got, "hello-app") + } +} + +func runSummerBuild(t *testing.T) string { + t.Helper() + cmd := exec.Command("go", "run", "../../cmd/summer", "build") + cmd.Dir = "." + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("summer build: %v\n%s", err, out) + } + return string(out) +} + +func generatedHash(t *testing.T) [32]byte { + t.Helper() + var buf bytes.Buffer + for _, name := range []string{"main.go", "plugins.gen.go"} { + body, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + buf.Write(body) + } + return sha256.Sum256(buf.Bytes()) +} diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 0000000..2409912 --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,308 @@ +package build + +import ( + "bytes" + "context" + "errors" + "fmt" + "go/format" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + "github.com/knadh/koanf/parsers/yaml" +) + +const manifestName = "summer.yaml" + +// Manifest is the ordered app plugin list used to generate imports. +type Manifest struct { + Module string + Binary string + Plugins []Plugin +} + +// Plugin is one compiled plugin module in manifest order. +type Plugin struct { + ID string + Module string +} + +// LoadManifest reads and validates summer.yaml at path. +func LoadManifest(path string) (Manifest, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Manifest{}, fmt.Errorf("build: read %s: %w", path, err) + } + tree, err := yaml.Parser().Unmarshal(raw) + if err != nil { + return Manifest{}, fmt.Errorf("build: parse %s: %w", path, err) + } + m := Manifest{ + Module: strings.TrimSpace(asString(tree["module"])), + Binary: strings.TrimSpace(asString(tree["binary"])), + } + if err := validateModulePath(m.Module); err != nil { + return Manifest{}, fmt.Errorf("build: module: %w", err) + } + if err := validateBinary(m.Binary); err != nil { + return Manifest{}, fmt.Errorf("build: binary: %w", err) + } + + seenID := make(map[string]struct{}) + seenMod := make(map[string]struct{}) + for i, item := range asSlice(tree["plugins"]) { + entry := asMap(item) + if entry == nil { + return Manifest{}, fmt.Errorf("build: plugins[%d] must be a mapping with id and module", i) + } + p := Plugin{ + ID: strings.TrimSpace(asString(entry["id"])), + Module: strings.TrimSpace(asString(entry["module"])), + } + if p.ID == "" { + return Manifest{}, fmt.Errorf("build: plugins[%d] is missing id", i) + } + if strings.ContainsAny(p.ID, " \t\r\n\"'`") { + return Manifest{}, fmt.Errorf("build: plugins[%d] has invalid id %q", i, p.ID) + } + if _, dup := seenID[p.ID]; dup { + return Manifest{}, fmt.Errorf("build: duplicate plugin id %q", p.ID) + } + if err := validateModulePath(p.Module); err != nil { + return Manifest{}, fmt.Errorf("build: plugins[%d] module: %w", i, err) + } + if _, dup := seenMod[p.Module]; dup { + return Manifest{}, fmt.Errorf("build: duplicate plugin module %q", p.Module) + } + seenID[p.ID] = struct{}{} + seenMod[p.Module] = struct{}{} + m.Plugins = append(m.Plugins, p) + } + return m, nil +} + +// App generates app sources from summer.yaml and runs go build in appDir. +func App(ctx context.Context, appDir string, out io.Writer) error { + if appDir == "" { + return fmt.Errorf("build: app directory is empty") + } + if !filepath.IsAbs(appDir) { + abs, err := filepath.Abs(appDir) + if err != nil { + return fmt.Errorf("build: resolve app directory: %w", err) + } + appDir = abs + } + if out == nil { + out = os.Stdout + } + m, err := LoadManifest(filepath.Join(appDir, manifestName)) + if err != nil { + return err + } + if err := generate(appDir, m); err != nil { + return err + } + + binRel := filepath.Join("bin", m.Binary) + if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o755); err != nil { + return fmt.Errorf("build: create bin dir: %w", err) + } + + cmd := exec.CommandContext(ctx, "go", "build", "-o", binRel, ".") + cmd.Dir = appDir + cmd.Stdout = out + cmd.Stderr = out + start := time.Now() + if err := cmd.Run(); err != nil { + return fmt.Errorf("build: go build: %w", err) + } + fmt.Fprintf(out, "built %s in %s\n", m.Binary, time.Since(start).Round(time.Millisecond)) + return nil +} + +func generate(appDir string, m Manifest) error { + mainSrc, err := generateMain(m) + if err != nil { + return err + } + pluginsSrc, err := generatePluginsGen(m) + if err != nil { + return err + } + if err := writeIfChanged(filepath.Join(appDir, "main.go"), mainSrc); err != nil { + return err + } + if err := writeIfChanged(filepath.Join(appDir, "plugins.gen.go"), pluginsSrc); err != nil { + return err + } + return nil +} + +func generateMain(m Manifest) ([]byte, error) { + var b strings.Builder + b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") + b.WriteString("package main\n\n") + b.WriteString("import (\n") + b.WriteString("\t\"fmt\"\n") + b.WriteString("\t\"io\"\n") + b.WriteString("\t\"os\"\n\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/bonfire\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/compass\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n") + b.WriteString(")\n\n") + b.WriteString("func main() {\n") + b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n") + b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n") + b.WriteString("\t\tos.Exit(1)\n") + b.WriteString("\t}\n") + b.WriteString("}\n\n") + b.WriteString("func run(args []string, out io.Writer) error {\n") + b.WriteString("\tcfg, err := compass.Load(\"config\")\n") + b.WriteString("\tif err != nil {\n") + b.WriteString("\t\treturn err\n") + b.WriteString("\t}\n") + b.WriteString("\tapp := backpack.New(cfg)\n") + b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n") + b.WriteString("\tif err != nil {\n") + b.WriteString("\t\treturn err\n") + b.WriteString("\t}\n") + b.WriteString("\tvar commands []bonfire.Command\n") + b.WriteString("\tfor _, plugin := range plugins {\n") + b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n") + b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n") + b.WriteString("\t\t}\n") + b.WriteString("\t}\n") + fmt.Fprintf(&b, "\troot := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary)) + b.WriteString("\troot.SetArgs(args)\n") + b.WriteString("\treturn root.Execute()\n") + b.WriteString("}\n") + return formatSource([]byte(b.String())) +} + +func generatePluginsGen(m Manifest) ([]byte, error) { + var b strings.Builder + b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") + b.WriteString("package main\n\n") + if len(m.Plugins) > 0 { + b.WriteString("import (\n") + for _, p := range m.Plugins { + fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module)) + } + b.WriteString(")\n\n") + } + b.WriteString("// PluginIDs is the ordered plugin list from summer.yaml.\n") + b.WriteString("var PluginIDs = []string{\n") + for _, p := range m.Plugins { + fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(p.ID)) + } + b.WriteString("}\n") + return formatSource([]byte(b.String())) +} + +func formatSource(src []byte) ([]byte, error) { + formatted, err := format.Source(src) + if err != nil { + return nil, fmt.Errorf("build: format generated go: %w", err) + } + return formatted, nil +} + +func writeIfChanged(path string, content []byte) error { + existing, err := os.ReadFile(path) + if err == nil && bytes.Equal(existing, content) { + return nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("build: read %s: %w", path, err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + return fmt.Errorf("build: write %s: %w", path, err) + } + return nil +} + +func validateBinary(name string) error { + if name == "" { + return fmt.Errorf("is empty") + } + if name != filepath.Base(name) || strings.ContainsRune(name, os.PathSeparator) { + return fmt.Errorf("%q must be a bare file name", name) + } + if name == "." || name == ".." { + return fmt.Errorf("%q is not a valid binary name", name) + } + for _, r := range name { + if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-') { + return fmt.Errorf("%q contains invalid characters", name) + } + } + return nil +} + +func validateModulePath(path string) error { + if path == "" { + return fmt.Errorf("is empty") + } + if strings.HasPrefix(path, "-") { + return fmt.Errorf("%q must not start with -", path) + } + if strings.ContainsAny(path, " \t\r\n\\\"'`$;&|*?<>()[]{}") { + return fmt.Errorf("%q contains invalid characters", path) + } + if filepath.IsAbs(path) || strings.Contains(path, `\`) { + return fmt.Errorf("%q is not a valid module path", path) + } + for _, seg := range strings.Split(path, "/") { + if seg == "" || seg == "." || seg == ".." { + return fmt.Errorf("%q has an invalid path segment", path) + } + if strings.Contains(seg, "@") { + return fmt.Errorf("%q must not contain a version suffix", path) + } + } + return nil +} + +func asString(v any) string { + s, _ := v.(string) + return s +} + +func asSlice(v any) []any { + switch s := v.(type) { + case []any: + return s + case nil: + return nil + default: + return nil + } +} + +func asMap(v any) map[string]any { + switch m := v.(type) { + case map[string]any: + return m + case map[any]any: + out := make(map[string]any, len(m)) + for k, val := range m { + ks, ok := k.(string) + if !ok { + continue + } + out[ks] = val + } + return out + default: + return nil + } +} diff --git a/internal/build/build_test.go b/internal/build/build_test.go new file mode 100644 index 0000000..28af067 --- /dev/null +++ b/internal/build/build_test.go @@ -0,0 +1,129 @@ +package build + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadHelloManifest(t *testing.T) { + m, err := LoadManifest(filepath.Join("..", "..", "examples", "hello", "summer.yaml")) + if err != nil { + t.Fatal(err) + } + if m.Binary != "hello" { + t.Fatalf("binary = %q", m.Binary) + } + if len(m.Plugins) != 2 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" { + t.Fatalf("plugins = %+v", m.Plugins) + } +} + +func TestParseManifestRejectsDuplicatesAndInvalidPaths(t *testing.T) { + dir := t.TempDir() + + t.Run("duplicate id", func(t *testing.T) { + path := filepath.Join(dir, "dup.yaml") + write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/one\n - id: a.b\n module: example.com/two\n") + if _, err := LoadManifest(path); err == nil || !strings.Contains(err.Error(), "a.b") { + t.Fatalf("want duplicate id error, got %v", err) + } + }) + + t.Run("invalid module", func(t *testing.T) { + path := filepath.Join(dir, "bad.yaml") + write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: \"example.com/one; rm -rf /\"\n") + if _, err := LoadManifest(path); err == nil { + t.Fatal("want invalid module error") + } + }) + + t.Run("path traversal module", func(t *testing.T) { + path := filepath.Join(dir, "dotdot.yaml") + write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/../evil\n") + if _, err := LoadManifest(path); err == nil { + t.Fatal("want invalid module error") + } + }) +} + +func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) { + m := Manifest{ + Module: "example.com/app", + Binary: "hello", + Plugins: []Plugin{ + {ID: "golem15.hello", Module: "example.com/app/plugins/base"}, + {ID: "golem15.greeter", Module: "example.com/app/plugins/greeter"}, + }, + } + first, err := generatePluginsGen(m) + if err != nil { + t.Fatal(err) + } + second, err := generatePluginsGen(m) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("generated plugins.gen.go is not byte-stable") + } + src := string(first) + baseAt := strings.Index(src, `example.com/app/plugins/base`) + greeterAt := strings.Index(src, `example.com/app/plugins/greeter`) + if baseAt < 0 || greeterAt < 0 || baseAt > greeterAt { + t.Fatalf("imports not in manifest order:\n%s", src) + } + if !strings.Contains(src, "\t_ \"example.com/app/plugins/base\"") { + t.Fatalf("import is not Go-quoted:\n%s", src) + } + + mainSrc, err := generateMain(m) + if err != nil { + t.Fatal(err) + } + again, err := generateMain(m) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(mainSrc, again) { + t.Fatal("generated main.go is not byte-stable") + } + if !bytes.Contains(mainSrc, []byte(`bonfire.NewRoot("hello"`)) { + t.Fatalf("main does not use manifest binary:\n%s", mainSrc) + } + if bytes.Contains(mainSrc, []byte("examples/hello")) { + t.Fatal("generated main hard-codes examples/hello") + } +} + +func TestWriteIfChangedSkipsIdenticalBytes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.go") + content := []byte("package main\n") + if err := writeIfChanged(path, content); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := writeIfChanged(path, content); err != nil { + t.Fatal(err) + } + again, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(again.ModTime()) { + t.Fatal("identical content rewrote the file") + } +} + +func write(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +}