Files
go-omni/cmd/go-omni/apigen/workspaceCatalog.go

218 lines
7.4 KiB
Go

package apigen
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
gotoken "go/token"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
)
// WorkspaceCatalog is an explicitly trusted Go provider: func() errors.Catalog.
// Only workspace generate/check execute providers. Parsing an .api never does.
type WorkspaceCatalog struct {
Dir string `json:"dir"`
Package string `json:"package"`
Function string `json:"function"`
Output string `json:"output"`
}
type limitedCatalogBuffer struct {
bytes.Buffer
limit int
}
func (b *limitedCatalogBuffer) Write(p []byte) (int, error) {
if len(p) > b.limit-b.Len() {
return 0, errors.New("catalog provider output exceeds limit")
}
return b.Buffer.Write(p)
}
func exportWorkspaceCatalog(root string, cfg WorkspaceCatalog) ([]byte, error) {
if cfg.Dir == "" || cfg.Output == "" || !gotoken.IsIdentifier(cfg.Function) || !gotoken.IsExported(cfg.Function) || module.CheckImportPath(cfg.Package) != nil {
return nil, errors.New("workspace catalog requires dir, an importable package, an exported function and output")
}
if err := safePath(root, filepath.ToSlash(filepath.Join(cfg.Dir, ".goOmniExportCheck"))); err != nil {
return nil, err
}
if err := safePath(root, cfg.Output); err != nil {
return nil, err
}
if filepath.Ext(cfg.Output) != ".json" {
return nil, errors.New("workspace catalog output must be .json")
}
dir := filepath.Join(root, filepath.FromSlash(cfg.Dir))
if stat, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || !stat.Mode().IsRegular() {
return nil, errors.New("workspace catalog dir must contain go.mod")
}
temp, err := os.MkdirTemp("", "go-omni-catalog-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(temp)
// Overlay a virtual Go file under the provider module. This allows internal
// packages without creating files or changing go.mod/go.sum in that module.
virtual := filepath.Join(dir, ".goOmniCatalogExport", "main.go")
if _, err := os.Lstat(filepath.Dir(virtual)); !errors.Is(err, os.ErrNotExist) {
return nil, errors.New("reserved catalog export path already exists")
}
source := fmt.Sprintf("package main\nimport (\"os\"; provider %q; omnierrors %q)\nfunc main(){if err:=omnierrors.ExportCatalog(os.Stdout,provider.%s());err!=nil{os.Exit(1)}}\n", cfg.Package, frameworkModule+"/errors", cfg.Function)
backing := filepath.Join(temp, "main.go")
if err = os.WriteFile(backing, []byte(source), 0600); err != nil {
return nil, err
}
overlay, _ := json.Marshal(map[string]any{"Replace": map[string]string{virtual: backing}})
overlayPath := filepath.Join(temp, "overlay.json")
if err = os.WriteFile(overlayPath, overlay, 0600); err != nil {
return nil, err
}
// Keep all module metadata writes in the temporary directory as well.
// -mod=readonly alone must not be relied on to protect every go.sum update.
moduleData, err := os.ReadFile(filepath.Join(dir, "go.mod"))
if err != nil {
return nil, err
}
mf, err := modfile.Parse("go.mod", moduleData, nil)
if err != nil {
return nil, errors.New("workspace catalog has invalid go.mod")
}
replacements := append([]*modfile.Replace(nil), mf.Replace...)
for _, r := range replacements {
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(dir, r.New.Path), ""); err != nil {
return nil, err
}
}
}
moduleData, err = mf.Format()
if err != nil {
return nil, err
}
modPath := filepath.Join(temp, "go.mod")
if err = os.WriteFile(modPath, moduleData, 0600); err != nil {
return nil, err
}
if sums, e := os.ReadFile(filepath.Join(dir, "go.sum")); e == nil {
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
return nil, err
}
} else if !errors.Is(e, os.ErrNotExist) {
return nil, e
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
executable := filepath.Join(temp, "catalogExport")
// Build first, then execute the binary directly: cancellation kills the
// provider itself instead of only terminating a go-run wrapper process.
build := exec.CommandContext(ctx, "go", "build", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath, "-o", executable, virtual)
build.Dir = dir
build.Env = append(os.Environ(), "GOWORK=off")
build.WaitDelay = 2 * time.Second
build.Stdout = &limitedCatalogBuffer{limit: 64 << 10}
build.Stderr = &limitedCatalogBuffer{limit: 64 << 10}
if err = build.Run(); err != nil {
return nil, fmt.Errorf("workspace catalog %s.%s: build failed (%v); check module dependencies and func() errors.Catalog signature", cfg.Package, cfg.Function, err)
}
cmd := exec.CommandContext(ctx, executable)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
cmd.WaitDelay = 2 * time.Second
stdout := &limitedCatalogBuffer{limit: 1 << 20}
stderr := &limitedCatalogBuffer{limit: 64 << 10}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err = cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("workspace catalog %s: %w", cfg.Package, ctx.Err())
}
// Provider output can contain arbitrary application data. Do not echo it.
return nil, fmt.Errorf("workspace catalog %s.%s: execution failed (%v); provider must return errors.Catalog without writing stdout", cfg.Package, cfg.Function, err)
}
var catalog apierrors.Catalog
if err = apierrors.DecodeCatalogJSON(stdout.Bytes(), &catalog); err != nil {
return nil, errors.New("workspace catalog provider did not emit a valid catalog")
}
var normalized bytes.Buffer
if err = apierrors.ExportCatalog(&normalized, catalog); err != nil {
return nil, err
}
return normalized.Bytes(), nil
}
func prepareWorkspaceCatalogs(root string, configs []WorkspaceCatalog, check bool) (map[string][]byte, error) {
if len(configs) > 16 {
return nil, errors.New("workspace supports at most 16 catalog providers")
}
result := map[string][]byte{}
for _, cfg := range configs {
if err := safePath(root, cfg.Output); err != nil {
return nil, err
}
path := filepath.Join(root, filepath.FromSlash(cfg.Output))
for prior := range result {
if strings.EqualFold(prior, path) {
return nil, errors.New("workspace catalog outputs overlap")
}
}
data, err := exportWorkspaceCatalog(root, cfg)
if err != nil {
return nil, err
}
if check {
old, err := os.ReadFile(path)
if err != nil || !bytes.Equal(old, data) {
return nil, fmt.Errorf("workspace catalog drift: %s; run workspace generate", cfg.Output)
}
}
result[path] = data
}
return result, nil
}
func writeWorkspaceCatalog(path string, data []byte) error {
mode := os.FileMode(0644)
if stat, err := os.Lstat(path); err == nil {
if !stat.Mode().IsRegular() {
return errors.New("workspace catalog output must be a regular file")
}
mode = stat.Mode().Perm()
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
old, err := os.ReadFile(path)
if err == nil && bytes.Equal(old, data) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), ".omni-catalog-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if err = f.Chmod(mode); err != nil {
_ = f.Close()
return err
}
_, writeErr := io.Copy(f, bytes.NewReader(data))
err = errors.Join(writeErr, f.Close())
if err != nil {
return err
}
return os.Rename(f.Name(), path)
}