218 lines
7.4 KiB
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)
|
|
}
|