303 lines
9.2 KiB
Go
303 lines
9.2 KiB
Go
package apigen
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
|
"go/ast"
|
|
"go/format"
|
|
goparser "go/parser"
|
|
gotoken "go/token"
|
|
"golang.org/x/mod/modfile"
|
|
"golang.org/x/mod/semver"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type UpgradeChange struct {
|
|
Path, Action string
|
|
Manual bool
|
|
before, after []byte
|
|
mode os.FileMode
|
|
exists bool
|
|
}
|
|
type UpgradePlan struct {
|
|
Changes []UpgradeChange
|
|
Module, Entry string
|
|
}
|
|
|
|
// PlanUpgrade compares infrastructure against the exact previous release seeds.
|
|
// A custom file is never classified as safe to replace. Logic, middleware,
|
|
// options and repositories outside the changed templates are left untouched.
|
|
func PlanUpgrade(dir string) (UpgradePlan, error) {
|
|
var plan UpgradePlan
|
|
root := canonical(dir)
|
|
if err := safePath(root, manifestPath); err != nil {
|
|
return plan, err
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(root, manifestPath))
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
var m manifest
|
|
if err = json.Unmarshal(raw, &m); err != nil {
|
|
return plan, err
|
|
}
|
|
if m.Version != 2 || m.Profile == nil {
|
|
return plan, errors.New("upgrade requires a managed v0.13+ project")
|
|
}
|
|
if filepath.Ext(m.Entry) != ".api" {
|
|
return plan, errors.New("upgrade requires a valid API entry")
|
|
}
|
|
if err = safePath(root, m.Entry); err != nil {
|
|
return plan, err
|
|
}
|
|
plan.Module, plan.Entry = m.Module, m.Entry
|
|
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, minimumVersion) >= 0 {
|
|
return plan, nil
|
|
}
|
|
p := m.Profile
|
|
legacy, err := scaffold.LegacyAPIInfrastructure(m.Module, p.Database, p.Storage, p.LogOutput, p.LogFormat)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
current, err := scaffold.APIInfrastructure(m.Module, p.Database, p.Storage, p.LogOutput, p.LogFormat)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
names := map[string]bool{}
|
|
for name := range legacy {
|
|
names[name] = true
|
|
}
|
|
for name := range current {
|
|
if !strings.HasSuffix(name, "Gen.go") {
|
|
names[name] = true
|
|
}
|
|
}
|
|
// v0.14 already owns config/svc extensions. Only bump the module here;
|
|
// Generate updates generator-owned main/runtime files without replaying the
|
|
// v0.13 seed migration over developer configuration and dependency code.
|
|
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, "v0.14.0-preview") >= 0 {
|
|
names = map[string]bool{}
|
|
}
|
|
sorted := make([]string, 0, len(names))
|
|
for name := range names {
|
|
sorted = append(sorted, name)
|
|
}
|
|
sort.Strings(sorted)
|
|
for _, name := range sorted {
|
|
// options.go is unchanged by this release. Its application customizations remain untouched.
|
|
previous, next := legacy[name], current[name]
|
|
if strings.HasSuffix(name, ".go") {
|
|
if previous != nil {
|
|
previous, err = format.Source(previous)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
}
|
|
if next != nil {
|
|
next, err = format.Source(next)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
}
|
|
}
|
|
if bytes.Equal(previous, next) {
|
|
continue
|
|
}
|
|
if err = safePath(root, name); err != nil {
|
|
return plan, err
|
|
}
|
|
existing, readErr := os.ReadFile(filepath.Join(root, name))
|
|
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
|
return plan, readErr
|
|
}
|
|
exists := readErr == nil
|
|
if bytes.Equal(existing, next) {
|
|
continue
|
|
}
|
|
mode := os.FileMode(0644)
|
|
if exists {
|
|
stat, e := os.Stat(filepath.Join(root, name))
|
|
if e != nil {
|
|
return plan, e
|
|
}
|
|
mode = stat.Mode().Perm()
|
|
}
|
|
action := "update"
|
|
if next == nil {
|
|
action = "remove"
|
|
} else if !exists {
|
|
action = "create"
|
|
}
|
|
plan.Changes = append(plan.Changes, UpgradeChange{Path: name, Action: action, Manual: exists && !bytes.Equal(existing, previous), before: existing, after: next, mode: mode, exists: exists})
|
|
}
|
|
if err = safePath(root, "go.mod"); err != nil {
|
|
return plan, err
|
|
}
|
|
raw, err = os.ReadFile(filepath.Join(root, "go.mod"))
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
mf, err := modfile.Parse("go.mod", raw, nil)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
for _, r := range mf.Require {
|
|
if r.Mod.Path == frameworkModule && semver.Compare(r.Mod.Version, minimumVersion) < 0 {
|
|
if err = mf.AddRequire(frameworkModule, minimumVersion); err != nil {
|
|
return plan, err
|
|
}
|
|
next, e := mf.Format()
|
|
if e != nil {
|
|
return plan, e
|
|
}
|
|
stat, e := os.Stat(filepath.Join(root, "go.mod"))
|
|
if e != nil {
|
|
return plan, e
|
|
}
|
|
plan.Changes = append(plan.Changes, UpgradeChange{Path: "go.mod", Action: "update", before: raw, after: next, exists: true, mode: stat.Mode().Perm()})
|
|
break
|
|
}
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
// PreviewUpgrade writes only proposed infrastructure files into an empty directory.
|
|
// It never mutates the service or emits a replacement business tree.
|
|
func PreviewUpgrade(dir, destination string) (UpgradePlan, error) {
|
|
plan, err := PlanUpgrade(dir)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
if entries, e := os.ReadDir(destination); e == nil && len(entries) > 0 {
|
|
return plan, errors.New("upgrade preview directory must be empty")
|
|
} else if e != nil && !errors.Is(e, os.ErrNotExist) {
|
|
return plan, e
|
|
}
|
|
for _, c := range plan.Changes {
|
|
if c.after == nil {
|
|
continue
|
|
}
|
|
if err = safePath(destination, c.Path); err != nil {
|
|
return plan, err
|
|
}
|
|
if err = atomicWrite(destination, c.Path, c.after, 0644); err != nil {
|
|
return plan, err
|
|
}
|
|
}
|
|
note := "# Infrastructure upgrade preview\n\nOnly untouched v0.13 seed files can be replaced automatically. Custom files require a targeted edit; this preview is never copied over the project automatically.\n\nMove dependency initialization into ServiceContext.Init; embed *lifecycle.Runtime; register cleanup with OnClose. Remove old New/Close/OnClose implementations after preserving their project work in Init or cleanup hooks. Use structured database/mysql.Config and DatabaseOptions. Preserve business configuration fields, AppOptions, middleware and Logic.\n\n"
|
|
for _, c := range plan.Changes {
|
|
note += fmt.Sprintf("- %s %s (manual review: %t)\n", c.Action, c.Path, c.Manual)
|
|
}
|
|
if err = safePath(destination, "UPGRADE.md"); err != nil {
|
|
return plan, err
|
|
}
|
|
return plan, atomicWrite(destination, "UPGRADE.md", []byte(note), 0644)
|
|
}
|
|
|
|
// ApplyUpgrade applies only recognized seed changes under the same generation
|
|
// lock. A generation failure restores every changed developer file. Any manual
|
|
// review item blocks all writes; use PreviewUpgrade and migrate that file first.
|
|
func ApplyUpgrade(dir string) (plan UpgradePlan, err error) {
|
|
root := canonical(dir)
|
|
if err = safePath(root, ".goOmni/api.lock"); err != nil {
|
|
return plan, err
|
|
}
|
|
lock, err := os.OpenFile(filepath.Join(root, ".goOmni/api.lock"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
_ = lock.Close()
|
|
defer os.Remove(filepath.Join(root, ".goOmni/api.lock"))
|
|
plan, err = PlanUpgrade(root)
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
for _, c := range plan.Changes {
|
|
if c.Manual {
|
|
return plan, fmt.Errorf("upgrade: %s contains project changes; inspect an upgrade preview and migrate it explicitly", c.Path)
|
|
}
|
|
}
|
|
var changed []UpgradeChange
|
|
defer func() {
|
|
if err != nil {
|
|
for i := len(changed) - 1; i >= 0; i-- {
|
|
c := changed[i]
|
|
if c.exists {
|
|
err = errors.Join(err, atomicWrite(root, c.Path, c.before, c.mode))
|
|
} else {
|
|
e := os.Remove(filepath.Join(root, c.Path))
|
|
if !errors.Is(e, os.ErrNotExist) {
|
|
err = errors.Join(err, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
for _, c := range plan.Changes {
|
|
if c.after == nil {
|
|
err = os.Remove(filepath.Join(root, c.Path))
|
|
} else {
|
|
err = atomicWrite(root, c.Path, c.after, c.mode)
|
|
}
|
|
if err != nil {
|
|
return plan, err
|
|
}
|
|
changed = append(changed, c)
|
|
}
|
|
preview, e := Generate(Options{Dir: root, API: filepath.Join(root, plan.Entry), DryRun: true, lockHeld: true})
|
|
if e != nil {
|
|
return plan, e
|
|
}
|
|
if diagnosticErrors(preview.Diagnostics) > 0 {
|
|
return plan, errors.New("upgrade: Logic checks failed; resolve diagnostics before applying")
|
|
}
|
|
_, err = Generate(Options{Dir: root, API: filepath.Join(root, plan.Entry), lockHeld: true})
|
|
return plan, err
|
|
}
|
|
|
|
func checkRuntimeExtension(root string) error {
|
|
directory := filepath.Join(root, "internal/svc")
|
|
files, err := os.ReadDir(directory)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range files {
|
|
if !sourceFile(entry.Name()) || strings.HasSuffix(entry.Name(), "Gen.go") {
|
|
continue
|
|
}
|
|
path := filepath.Join(directory, entry.Name())
|
|
file, err := goparser.ParseFile(gotoken.NewFileSet(), path, nil, goparser.SkipObjectResolution)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, decl := range file.Decls {
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if fn.Recv == nil && fn.Name.Name == "New" {
|
|
return fmt.Errorf("%s owns New; run go-omni api upgrade --dir %s and move project initialization into ServiceContext.Init", path, root)
|
|
}
|
|
if fn.Recv != nil && (fn.Name.Name == "Close" || fn.Name.Name == "OnClose" || fn.Name.Name == "Finish") {
|
|
receiver := fn.Recv.List[0].Type
|
|
if p, ok := receiver.(*ast.StarExpr); ok {
|
|
receiver = p.X
|
|
}
|
|
if id, ok := receiver.(*ast.Ident); ok && id.Name == "ServiceContext" {
|
|
return fmt.Errorf("%s overrides framework resource lifecycle; inspect go-omni api upgrade and register cleanup in Init", path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|