feat: release v0.28 generic Go project scaffolding and lifecycle entry
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# go-omni CLI
|
||||
|
||||
v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。
|
||||
|
||||
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。
|
||||
|
||||
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
|
||||
@@ -10,13 +12,13 @@ v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 work
|
||||
|
||||
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
||||
|
||||
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||
API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||
|
||||
## 创建项目
|
||||
## 创建 API 项目
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -51,14 +53,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.27.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.28.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
当前模板与依赖以 v0.27.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
当前模板与依赖以 v0.28.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
@@ -22,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||
const minimumVersion = "v0.22.0-preview"
|
||||
const currentVersion = "v0.27.0-preview"
|
||||
const currentVersion = "v0.28.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
@@ -345,25 +346,7 @@ func moduleFile(o Options) ([]byte, error) {
|
||||
}
|
||||
return []byte(text), nil
|
||||
}
|
||||
func canonical(path string) string {
|
||||
path, _ = filepath.Abs(path)
|
||||
candidate := path
|
||||
var tail []string
|
||||
for {
|
||||
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
resolved = filepath.Join(resolved, tail[i])
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if parent == candidate {
|
||||
return path
|
||||
}
|
||||
tail = append(tail, filepath.Base(candidate))
|
||||
candidate = parent
|
||||
}
|
||||
}
|
||||
func canonical(path string) string { return genfs.Canonical(path) }
|
||||
|
||||
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
|
||||
generated, seeds := map[string][]byte{}, map[string][]byte{}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -244,44 +245,13 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
}
|
||||
return result, checkErr
|
||||
}
|
||||
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
||||
// File replacement is atomic; the whole directory is not crash-atomic.
|
||||
var applied []change
|
||||
transaction := make([]genfs.Change, 0, len(changes))
|
||||
for _, c := range changes {
|
||||
if err = safePath(root, c.name); err == nil {
|
||||
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.name)))
|
||||
if c.existed {
|
||||
if e != nil || !bytes.Equal(current, c.before) {
|
||||
err = fmt.Errorf("file changed during generation: %s", c.name)
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
err = fmt.Errorf("file appeared during generation: %s", c.name)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
if c.remove {
|
||||
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.name)))
|
||||
} else {
|
||||
err = atomicWrite(root, c.name, c.after, c.mode)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
for i := len(applied) - 1; i >= 0; i-- {
|
||||
previous := applied[i]
|
||||
var rollback error
|
||||
if previous.existed {
|
||||
rollback = atomicWrite(root, previous.name, previous.before, previous.mode)
|
||||
} else {
|
||||
rollback = os.Remove(filepath.Join(root, filepath.FromSlash(previous.name)))
|
||||
}
|
||||
err = errors.Join(err, rollback)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
applied = append(applied, c)
|
||||
transaction = append(transaction, genfs.Change{Name: c.name, Before: c.before, After: c.after, Existed: c.existed, Remove: c.remove, Mode: c.mode})
|
||||
}
|
||||
return result, nil
|
||||
return result, genfs.Apply(root, transaction)
|
||||
}
|
||||
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
func ownedPath(name string) bool {
|
||||
if name == "internal/config/loadGen.go" {
|
||||
@@ -298,52 +268,7 @@ func ownedPath(name string) bool {
|
||||
}
|
||||
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
|
||||
}
|
||||
func safePath(root, name string) error {
|
||||
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
||||
return fmt.Errorf("api: unsafe output path %q", name)
|
||||
}
|
||||
path := root
|
||||
parts := strings.Split(name, "/")
|
||||
for i, part := range parts {
|
||||
path = filepath.Join(path, part)
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("api: refusing symlink %s", path)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("api: expected directory %s", path)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("api: expected regular file %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func safePath(root, name string) error { return genfs.SafePath(root, name) }
|
||||
func atomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(target), ".apiGen-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err = f.Chmod(mode); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), target)
|
||||
return genfs.AtomicWrite(root, name, data, mode)
|
||||
}
|
||||
|
||||
133
cmd/go-omni/internal/genfs/files.go
Normal file
133
cmd/go-omni/internal/genfs/files.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package genfs shares guarded filesystem operations between project generators.
|
||||
package genfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SafePath(root, name string) error {
|
||||
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
||||
return fmt.Errorf("generation: unsafe output path %q", name)
|
||||
}
|
||||
path := root
|
||||
parts := strings.Split(name, "/")
|
||||
for i, part := range parts {
|
||||
path = filepath.Join(path, part)
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("generation: refusing symlink %s", path)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("generation: expected directory %s", path)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("generation: expected regular file %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func AtomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(target), ".omniGen-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err = f.Chmod(mode); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), target)
|
||||
}
|
||||
|
||||
// Change records the preflight snapshot used for conflict detection and rollback.
|
||||
type Change struct {
|
||||
Name string
|
||||
Before, After []byte
|
||||
Existed, Remove bool
|
||||
Mode os.FileMode
|
||||
}
|
||||
|
||||
// Apply replaces individual files atomically and rolls back ordinary write failures.
|
||||
// Callers serialize generators with a project lock. This is not crash-atomic across files.
|
||||
func Apply(root string, changes []Change) error {
|
||||
var applied []Change
|
||||
for _, c := range changes {
|
||||
err := SafePath(root, c.Name)
|
||||
if err == nil {
|
||||
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.Name)))
|
||||
if c.Existed {
|
||||
if e != nil || !bytes.Equal(current, c.Before) {
|
||||
err = fmt.Errorf("file changed during generation: %s", c.Name)
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
err = fmt.Errorf("file appeared during generation: %s", c.Name)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
if c.Remove {
|
||||
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.Name)))
|
||||
} else {
|
||||
err = AtomicWrite(root, c.Name, c.After, c.Mode)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
for i := len(applied) - 1; i >= 0; i-- {
|
||||
previous := applied[i]
|
||||
if e := SafePath(root, previous.Name); e != nil {
|
||||
err = errors.Join(err, e)
|
||||
continue
|
||||
}
|
||||
if previous.Existed {
|
||||
err = errors.Join(err, AtomicWrite(root, previous.Name, previous.Before, previous.Mode))
|
||||
} else {
|
||||
err = errors.Join(err, os.Remove(filepath.Join(root, filepath.FromSlash(previous.Name))))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
applied = append(applied, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Canonical resolves existing parent symlinks even for an output not yet created.
|
||||
func Canonical(path string) string {
|
||||
path, _ = filepath.Abs(path)
|
||||
candidate := path
|
||||
var tail []string
|
||||
for {
|
||||
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
resolved = filepath.Join(resolved, tail[i])
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if parent == candidate {
|
||||
return path
|
||||
}
|
||||
tail = append(tail, filepath.Base(candidate))
|
||||
candidate = parent
|
||||
}
|
||||
}
|
||||
47
cmd/go-omni/internal/genfs/files_test.go
Normal file
47
cmd/go-omni/internal/genfs/files_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package genfs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConflictRollsBackEarlierWrites(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "one"), []byte("before"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "two"), []byte("concurrent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := Apply(root, []Change{{Name: "one", Before: []byte("before"), After: []byte("after"), Existed: true, Mode: 0600}, {Name: "new", After: []byte("created"), Mode: 0644}, {Name: "two", Before: []byte("stale"), After: []byte("after"), Existed: true, Mode: 0644}})
|
||||
if err == nil {
|
||||
t.Fatal("conflict accepted")
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(root, "one"))
|
||||
info, _ := os.Stat(filepath.Join(root, "one"))
|
||||
if string(data) != "before" || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("rollback did not restore bytes/mode")
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) {
|
||||
t.Fatal("new output survived rollback")
|
||||
}
|
||||
data, _ = os.ReadFile(filepath.Join(root, "two"))
|
||||
if string(data) != "concurrent" {
|
||||
t.Fatal("concurrent edit overwritten")
|
||||
}
|
||||
}
|
||||
func TestUnsafePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"../escape", "a/../../escape", "/outside", "a\\b", ".", "a/../b"} {
|
||||
if SafePath(root, name) == nil {
|
||||
t.Fatal(name)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(t.TempDir(), filepath.Join(root, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if SafePath(root, "link/file") == nil {
|
||||
t.Fatal("followed symlink")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ func main() {
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "project":
|
||||
if err := runProject(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni project:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "i18n":
|
||||
if err := runI18n(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni i18n:", err)
|
||||
@@ -175,6 +180,7 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, "GoOmni project tool")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Usage:")
|
||||
fmt.Fprintln(os.Stderr, " go-omni project <new|generate|check> Generate a transport-independent Go program")
|
||||
fmt.Fprintln(os.Stderr, " go-omni i18n <check|export|types|extract> Validate and export independent language resources")
|
||||
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
|
||||
@@ -217,6 +223,9 @@ func runAdd(args []string) error {
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(directory, ".goOmni", "projectManifest.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("plain Go projects add packages as ordinary Go code; use project generate for infrastructure")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil {
|
||||
return errors.New("managed API projects add modules through .api imports and api generate")
|
||||
}
|
||||
|
||||
102
cmd/go-omni/project.go
Normal file
102
cmd/go-omni/project.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||
)
|
||||
|
||||
func runProject(args []string) error {
|
||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||
projectUsage()
|
||||
return nil
|
||||
}
|
||||
command := args[0]
|
||||
if command != "new" && command != "generate" && command != "check" {
|
||||
return fmt.Errorf("unknown project command %q", command)
|
||||
}
|
||||
o := projectgen.Options{Check: command == "check"}
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--help" || arg == "-h" {
|
||||
projectUsage()
|
||||
return nil
|
||||
}
|
||||
if arg == "--check" || arg == "--dry-run" {
|
||||
if command != "generate" {
|
||||
return fmt.Errorf("%s belongs to project generate", arg)
|
||||
}
|
||||
if arg == "--check" {
|
||||
o.Check = true
|
||||
} else {
|
||||
o.DryRun = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
if command != "new" || o.Dir != "" {
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
o.Dir = arg
|
||||
continue
|
||||
}
|
||||
name, value, inline := strings.Cut(arg, "=")
|
||||
switch name {
|
||||
case "--dir", "--module", "--framework-dir", "--framework-version":
|
||||
default:
|
||||
return fmt.Errorf("unknown project option %q", name)
|
||||
}
|
||||
if command != "new" && name != "--dir" {
|
||||
return fmt.Errorf("%s belongs to project new", name)
|
||||
}
|
||||
if !inline {
|
||||
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
i++
|
||||
value = args[i]
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
switch name {
|
||||
case "--dir":
|
||||
if o.Dir != "" {
|
||||
return errors.New("project directory specified more than once")
|
||||
}
|
||||
o.Dir = value
|
||||
case "--module":
|
||||
o.Module = value
|
||||
case "--framework-dir":
|
||||
o.FrameworkDir = value
|
||||
case "--framework-version":
|
||||
o.FrameworkVersion = value
|
||||
}
|
||||
}
|
||||
var out projectgen.Output
|
||||
var err error
|
||||
if command == "new" {
|
||||
out, err = projectgen.New(o)
|
||||
} else {
|
||||
out, err = projectgen.Generate(o)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.DryRun {
|
||||
for _, change := range out.Changes {
|
||||
fmt.Printf("%-7s %s\n", change.Action, change.Path)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("project %s: %d file changes\n", command, len(out.Changes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func projectUsage() {
|
||||
fmt.Println("go-omni project new <directory> [--module <module>] [--framework-version <version>|--framework-dir <directory>]")
|
||||
fmt.Println("go-omni project generate [--dir <project>] [--check|--dry-run]")
|
||||
fmt.Println("go-omni project check [--dir <project>]")
|
||||
fmt.Println("Creates a plain Go program with config, logging, dependency lifecycle and an editable Run entry.")
|
||||
}
|
||||
27
cmd/go-omni/project_test.go
Normal file
27
cmd/go-omni/project_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectCLI(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "program")
|
||||
for _, args := range [][]string{{"new", root, "--module", "example.com/program"}, {"generate", "--dir", root, "--dry-run"}, {"generate", "--dir", root}, {"check", "--dir", root}, {"generate", "--dir", root, "--check"}} {
|
||||
if err := runProject(args); err != nil {
|
||||
t.Fatal(args, err)
|
||||
}
|
||||
}
|
||||
if err := runAdd([]string{"item", "--dir", root}); err == nil {
|
||||
t.Fatal("add adopted plain project")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "internal/item")); !os.IsNotExist(err) {
|
||||
t.Fatal("business module generated")
|
||||
}
|
||||
for _, args := range [][]string{{"new"}, {"new", "a", "b"}, {"new", "--api", "x"}, {"check", "--dry-run"}, {"generate", "--check", "--dry-run"}, {"generate", "--module", "x"}, {"new", "--module"}, {"new", "--dir="}, {"new", "--dir", "x", "y"}, {"unknown"}} {
|
||||
if err := runProject(args); err == nil {
|
||||
t.Fatal("accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
190
cmd/go-omni/projectgen/consumer_test.go
Normal file
190
cmd/go-omni/projectgen/consumer_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package projectgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGeneratedProgramConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
root := filepath.Join(t.TempDir(), "program")
|
||||
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
if cli != "" {
|
||||
runCommand(t, "", cli, "project", "new", root, "--module", "example.com/plain")
|
||||
} else {
|
||||
framework, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = New(Options{Dir: root, Module: "example.com/plain", FrameworkDir: framework}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Only project-owned files are customized. All entry/loading/lifecycle code
|
||||
// remains exactly what the local or downloaded CLI generated.
|
||||
svc := get(t, root, "internal/svc/serviceContext.go")
|
||||
svc = strings.Replace(svc, "return nil", "return Initialize(ctx, c)", 1)
|
||||
svc += "\nvar Initialize = func(context.Context, *ServiceContext) error { return nil }\n"
|
||||
put(t, root, "internal/svc/serviceContext.go", svc)
|
||||
put(t, root, "internal/run/run.go", `package run
|
||||
import("context";"errors";"os";"example.com/plain/internal/svc")
|
||||
var Execute=func(ctx context.Context,deps *svc.ServiceContext)error{
|
||||
switch os.Getenv("GO_OMNI_PROGRAM_TEST_MODE") {case "error":return errors.New("process failure marker");case "panic":panic("process panic marker");case "unresponsive":select{}}
|
||||
<-ctx.Done();return nil
|
||||
}
|
||||
func Run(ctx context.Context,deps *svc.ServiceContext)error{return Execute(ctx,deps)}
|
||||
`)
|
||||
put(t, root, "consumer_test.go", get(t, "testdata", "consumer_test.go"))
|
||||
runCommand(t, root, "go", "mod", "tidy")
|
||||
runCommand(t, root, "go", "test", "-race", "-count=1", "./...")
|
||||
runCommand(t, root, "go", "vet", "./...")
|
||||
binary := filepath.Join(root, "program")
|
||||
runCommand(t, root, "go", "build", "-race", "-o", binary, ".")
|
||||
before := snapshot(t, root)
|
||||
for _, mode := range []string{"generate", "check", "dry-run"} {
|
||||
if cli != "" {
|
||||
args := []string{"project", mode, "--dir", root}
|
||||
if mode == "dry-run" {
|
||||
args = []string{"project", "generate", "--dir", root, "--dry-run"}
|
||||
}
|
||||
runCommand(t, root, cli, args...)
|
||||
} else {
|
||||
if _, err := Generate(Options{Dir: root, Check: mode == "check", DryRun: mode == "dry-run"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
after := snapshot(t, root)
|
||||
for name, data := range before {
|
||||
if after[name] != data {
|
||||
t.Fatal("consumer changed during regeneration", name)
|
||||
}
|
||||
}
|
||||
// Validation must not open even the configured log file.
|
||||
logfile := filepath.Join(root, "check-must-not-open.log")
|
||||
cfg := filepath.Join(root, "check.json")
|
||||
raw, _ := json.Marshal(map[string]any{"log": map[string]any{"file": logfile}})
|
||||
put(t, root, "check.json", string(raw))
|
||||
runCommand(t, root, binary, "-config", cfg, "-check-config")
|
||||
if _, err := os.Stat(logfile); !os.IsNotExist(err) {
|
||||
t.Fatal("check-config opened logger", err)
|
||||
}
|
||||
put(t, root, "check.json", `{"log":{"level":"invalid"}}`)
|
||||
cmd := exec.Command(binary, "-config", cfg, "-check-config")
|
||||
cmd.Dir = root
|
||||
if data, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(data), "log.level") {
|
||||
t.Fatal("invalid config accepted", string(data), err)
|
||||
}
|
||||
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
|
||||
testProcess(t, root, binary, sig, "")
|
||||
}
|
||||
for _, mode := range []string{"error", "panic", "unresponsive"} {
|
||||
var sig os.Signal
|
||||
if mode == "unresponsive" {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
testProcess(t, root, binary, sig, mode)
|
||||
}
|
||||
if cli != "" {
|
||||
data := runCommand(t, root, "go", "list", "-m", "-json", frameworkModule)
|
||||
var dep struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &dep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Replace != nil || dep.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer", dep)
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum)
|
||||
}
|
||||
}
|
||||
func runCommand(t *testing.T, dir, name string, args ...string) string {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, output)
|
||||
}
|
||||
t.Logf("%s %v\n%s", name, args, output)
|
||||
return string(output)
|
||||
}
|
||||
func testProcess(t *testing.T, root, binary string, sig os.Signal, mode string) {
|
||||
t.Helper()
|
||||
logfile, err := os.CreateTemp(root, "signal-*.log")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logfile.Close()
|
||||
cfg := filepath.Join(root, "process.json")
|
||||
put(t, root, "process.json", `{"lifecycle":{"stopTimeout":"100ms","drainTimeout":"30ms","cancelTimeout":"100ms"}}`)
|
||||
cmd := exec.Command(binary, "-config", cfg)
|
||||
cmd.Env = append(os.Environ(), "GO_OMNI_PROGRAM_TEST_MODE="+mode)
|
||||
cmd.Dir = root
|
||||
cmd.Stdout = logfile
|
||||
cmd.Stderr = logfile
|
||||
if err = cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
defer func() { _ = cmd.Process.Kill() }()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
data, _ := os.ReadFile(logfile.Name())
|
||||
if strings.Contains(string(data), "program.starting") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("program did not start", string(data))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if path, err := exec.LookPath("lsof"); err == nil {
|
||||
output, _ := exec.Command(path, "-nP", "-a", "-p", strconv.Itoa(cmd.Process.Pid), "-i").CombinedOutput()
|
||||
if strings.Contains(string(output), "TCP") || strings.Contains(string(output), "UDP") {
|
||||
t.Fatal("starter opened network socket", string(output))
|
||||
}
|
||||
}
|
||||
if sig != nil {
|
||||
if err = cmd.Process.Signal(sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err = <-done:
|
||||
data, _ := os.ReadFile(logfile.Name())
|
||||
if mode == "" && err != nil {
|
||||
t.Fatal("signal exit failed", err, string(data))
|
||||
}
|
||||
if mode != "" {
|
||||
exit, ok := err.(*exec.ExitError)
|
||||
if !ok || exit.ExitCode() != 1 {
|
||||
t.Fatal("abnormal process exit status", err, string(data))
|
||||
}
|
||||
}
|
||||
if mode == "unresponsive" && !strings.Contains(string(data), "shared resources retained") {
|
||||
t.Fatal("timeout did not retain resources", string(data))
|
||||
}
|
||||
t.Logf("process mode=%q signal=%v passed", mode, sig)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("signal exit timed out")
|
||||
}
|
||||
}
|
||||
359
cmd/go-omni/projectgen/generate.go
Normal file
359
cmd/go-omni/projectgen/generate.go
Normal file
@@ -0,0 +1,359 @@
|
||||
// Package projectgen generates transport-independent Go programs.
|
||||
package projectgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const currentVersion = "v0.28.0-preview"
|
||||
const minimumVersion = "v0.28.0-preview"
|
||||
const manifestPath = ".goOmni/projectManifest.json"
|
||||
const lockPath = ".goOmni/project.lock"
|
||||
const header = "// Code generated by go-omni project. DO NOT EDIT.\n"
|
||||
|
||||
//go:embed templates/*
|
||||
var templates embed.FS
|
||||
|
||||
var managed = map[string]bool{"main.go": true, "internal/config/loadGen.go": true, "internal/svc/runtimeGen.go": true}
|
||||
var seeds = []string{"go.mod", ".gitignore", "README.md", "etc/config.yaml", "internal/config/config.go", "internal/config/loader.go", "internal/svc/serviceContext.go", "internal/run/run.go"}
|
||||
|
||||
type Options struct {
|
||||
Dir, Module, FrameworkDir, FrameworkVersion string
|
||||
Check, DryRun bool
|
||||
}
|
||||
type FileChange struct{ Path, Action string }
|
||||
type Output struct{ Changes []FileChange }
|
||||
type manifest struct {
|
||||
Version int `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
TemplateVersion string `json:"templateVersion"`
|
||||
Module string `json:"module"`
|
||||
Generated map[string]string `json:"generated"`
|
||||
}
|
||||
|
||||
// New only accepts an empty directory. FrameworkDir is an explicit development opt-in.
|
||||
func New(o Options) (Output, error) {
|
||||
if o.Dir == "" || o.Check || o.DryRun {
|
||||
return Output{}, errors.New("project new requires a directory and does not accept --check/--dry-run")
|
||||
}
|
||||
if entries, err := os.ReadDir(o.Dir); err == nil && len(entries) > 0 {
|
||||
return Output{}, errors.New("project new requires an empty directory")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return Output{}, err
|
||||
}
|
||||
if o.Module == "" {
|
||||
o.Module = filepath.Base(genfs.Canonical(o.Dir))
|
||||
}
|
||||
if err := module.CheckImportPath(o.Module); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
if _, err := moduleFile(o, genfs.Canonical(o.Dir), true); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
return generate(o, true)
|
||||
}
|
||||
func Generate(o Options) (Output, error) { return generate(o, false) }
|
||||
|
||||
func generate(o Options, create bool) (out Output, err error) {
|
||||
if o.Check && o.DryRun {
|
||||
return out, errors.New("--check and --dry-run cannot be combined")
|
||||
}
|
||||
if o.Dir == "" {
|
||||
o.Dir = "."
|
||||
}
|
||||
root, err := filepath.Abs(o.Dir)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if info, e := os.Lstat(root); e == nil {
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return out, errors.New("project directory must be a real directory")
|
||||
}
|
||||
} else if !create || !errors.Is(e, os.ErrNotExist) {
|
||||
return out, e
|
||||
}
|
||||
root = genfs.Canonical(root)
|
||||
for _, name := range append(append([]string{}, seeds...), manifestPath, lockPath) {
|
||||
if err = genfs.SafePath(root, name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for name := range managed {
|
||||
if err = genfs.SafePath(root, name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, other := range []string{".goOmni/apiManifest.json", "api/service.api"} {
|
||||
if _, e := os.Lstat(filepath.Join(root, other)); !errors.Is(e, os.ErrNotExist) {
|
||||
return out, errors.New("project generator requires its own project; existing API layout is not adopted")
|
||||
}
|
||||
}
|
||||
if !o.Check && !o.DryRun {
|
||||
if err = os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
|
||||
return out, err
|
||||
}
|
||||
lock, e := os.OpenFile(filepath.Join(root, lockPath), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if e != nil {
|
||||
return out, fmt.Errorf("acquire project.lock (check for a running generator before removing a stale lock): %w", e)
|
||||
}
|
||||
_, err = fmt.Fprintf(lock, "pid=%d\n", os.Getpid())
|
||||
err = errors.Join(err, lock.Close())
|
||||
defer os.Remove(filepath.Join(root, lockPath))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
var old manifest
|
||||
oldData, readErr := os.ReadFile(filepath.Join(root, manifestPath))
|
||||
if create {
|
||||
if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return out, errors.New("project manifest appeared during creation")
|
||||
}
|
||||
// Recheck after taking the lock; never adopt files created concurrently.
|
||||
entries, e := os.ReadDir(root)
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != ".goOmni" {
|
||||
return out, errors.New("project directory changed during creation")
|
||||
}
|
||||
}
|
||||
entries, e = os.ReadDir(filepath.Join(root, ".goOmni"))
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != "project.lock" {
|
||||
return out, errors.New("project metadata appeared during creation")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if readErr != nil {
|
||||
return out, fmt.Errorf("read project manifest (create with project new first): %w", readErr)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(oldData))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err = decoder.Decode(&old); err != nil {
|
||||
return out, fmt.Errorf("invalid project manifest: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if decoder.Decode(&extra) != io.EOF {
|
||||
return out, errors.New("invalid trailing project manifest data")
|
||||
}
|
||||
if old.Version != 1 || old.Kind != "project" || old.Module == "" || len(old.Generated) != len(managed) || !semver.IsValid(old.TemplateVersion) || semver.Compare(old.TemplateVersion, minimumVersion) > 0 {
|
||||
return out, errors.New("unsupported project manifest; use a compatible CLI")
|
||||
}
|
||||
if o.Module != "" && o.Module != old.Module {
|
||||
return out, errors.New("module differs from project manifest")
|
||||
}
|
||||
o.Module = old.Module
|
||||
for name := range managed {
|
||||
if len(old.Generated[name]) != 64 {
|
||||
return out, fmt.Errorf("invalid manifest ownership for %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if o.Module == "" {
|
||||
o.Module = filepath.Base(root)
|
||||
}
|
||||
if err = module.CheckImportPath(o.Module); err != nil {
|
||||
return out, err
|
||||
}
|
||||
moduleData, err := moduleFile(o, root, create)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
rendered, err := render(o.Module, moduleData)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
next := manifest{Version: 1, Kind: "project", TemplateVersion: minimumVersion, Module: o.Module, Generated: map[string]string{}}
|
||||
var changes []genfs.Change
|
||||
names := make([]string, 0, len(rendered))
|
||||
for name := range rendered {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
before, e := os.ReadFile(target)
|
||||
exists := e == nil
|
||||
if e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||
return out, e
|
||||
}
|
||||
generated := managed[name]
|
||||
if !generated && !create {
|
||||
if !exists {
|
||||
return out, fmt.Errorf("project-owned file %s is missing; restore it explicitly", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if generated {
|
||||
next.Generated[name] = digest(rendered[name])
|
||||
if exists && (create || digest(before) != old.Generated[name]) {
|
||||
return out, fmt.Errorf("generated file %s was edited; move custom code to project-owned files", name)
|
||||
}
|
||||
} else if exists {
|
||||
return out, fmt.Errorf("refusing to adopt %s", name)
|
||||
}
|
||||
if exists && bytes.Equal(before, rendered[name]) {
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0644)
|
||||
if exists {
|
||||
info, e := os.Stat(target)
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
changes = append(changes, genfs.Change{Name: name, Before: before, After: rendered[name], Existed: exists, Mode: mode})
|
||||
}
|
||||
data, err := json.MarshalIndent(next, "", " ")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if !bytes.Equal(oldData, data) {
|
||||
changes = append(changes, genfs.Change{Name: manifestPath, Before: oldData, After: data, Existed: oldData != nil, Mode: 0644})
|
||||
}
|
||||
for _, c := range changes {
|
||||
action := "create"
|
||||
if c.Existed {
|
||||
action = "update"
|
||||
}
|
||||
out.Changes = append(out.Changes, FileChange{c.Name, action})
|
||||
}
|
||||
sort.Slice(out.Changes, func(i, j int) bool { return out.Changes[i].Path < out.Changes[j].Path })
|
||||
if o.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
if o.Check {
|
||||
if len(changes) > 0 {
|
||||
return out, fmt.Errorf("project generation drift: %v", out.Changes)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return out, genfs.Apply(root, changes)
|
||||
}
|
||||
|
||||
func moduleFile(o Options, root string, create bool) ([]byte, error) {
|
||||
if !create {
|
||||
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", data, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mf.Module == nil || mf.Module.Mod.Path != o.Module {
|
||||
return nil, errors.New("go.mod module differs from project manifest")
|
||||
}
|
||||
if o.FrameworkDir != "" || o.FrameworkVersion != "" {
|
||||
return nil, errors.New("framework options belong to project new; update go.mod explicitly")
|
||||
}
|
||||
for _, r := range mf.Require {
|
||||
if r.Mod.Path == frameworkModule && semver.Compare(r.Mod.Version, minimumVersion) >= 0 {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("go.mod must require go-omni %s or newer", minimumVersion)
|
||||
}
|
||||
version := o.FrameworkVersion
|
||||
if version == "" {
|
||||
version = currentVersion
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||
version = info.Main.Version
|
||||
}
|
||||
}
|
||||
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" || semver.Compare(version, minimumVersion) < 0 {
|
||||
return nil, fmt.Errorf("framework version must be %s or newer", minimumVersion)
|
||||
}
|
||||
mf := new(modfile.File)
|
||||
if err := mf.AddModuleStmt(o.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mf.AddGoStmt("1.22.0"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mf.AddRequire(frameworkModule, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
local, err := filepath.Abs(o.FrameworkDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(local, "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := modfile.Parse("go.mod", data, nil)
|
||||
if err != nil || source.Module == nil || source.Module.Mod.Path != frameworkModule {
|
||||
return nil, errors.New("--framework-dir must contain go-omni")
|
||||
}
|
||||
local = genfs.Canonical(local)
|
||||
rel, err := filepath.Rel(root, local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !strings.HasPrefix(rel, ".") {
|
||||
rel = "./" + rel
|
||||
}
|
||||
if err = mf.AddReplace(frameworkModule, "", rel, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return mf.Format()
|
||||
}
|
||||
func render(moduleName string, moduleData []byte) (map[string][]byte, error) {
|
||||
files := map[string][]byte{"go.mod": moduleData}
|
||||
paths := append([]string{}, seeds[1:]...)
|
||||
for name := range managed {
|
||||
paths = append(paths, name)
|
||||
}
|
||||
for _, name := range paths {
|
||||
raw, err := templates.ReadFile("templates/" + strings.ReplaceAll(name, "/", "_") + ".tmpl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := strings.ReplaceAll(string(raw), "\"PROJECT_MODULE\"", strconv.Quote(moduleName))
|
||||
s = strings.ReplaceAll(s, "PROJECT_MODULE/", moduleName+"/")
|
||||
if managed[name] {
|
||||
s = header + s
|
||||
}
|
||||
data := []byte(s)
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
data, err = format.Source(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("format %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
files[name] = data
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
215
cmd/go-omni/projectgen/generate_test.go
Normal file
215
cmd/go-omni/projectgen/generate_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package projectgen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func put(t *testing.T, root, name, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func get(t *testing.T, root, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
func snapshot(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
files := map[string]string{}
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
data, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
files[rel] = string(data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
func fresh(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "program")
|
||||
if _, err := New(Options{Dir: root, Module: "example.com/program"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestOwnershipAndDrift(t *testing.T) {
|
||||
root := fresh(t)
|
||||
files := snapshot(t, root)
|
||||
for name, source := range files {
|
||||
if strings.HasSuffix(name, ".api") || strings.Contains(name, "handler") || strings.Contains(name, "openapi") {
|
||||
t.Fatal("unexpected API output", name)
|
||||
}
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
for _, forbidden := range []string{"net/http", "httpx", "middleware", "ListenAndServe", "database/", "queue/", "task.New"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatal(name, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(files["go.mod"], "replace") || !strings.Contains(files["go.mod"], currentVersion) {
|
||||
t.Fatal("default dependency must be remote and fixed")
|
||||
}
|
||||
for _, name := range seeds {
|
||||
if name != "go.mod" {
|
||||
put(t, root, name, get(t, root, name)+"\n// project edit\n")
|
||||
}
|
||||
}
|
||||
before := snapshot(t, root)
|
||||
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
|
||||
out, err := Generate(o)
|
||||
if err != nil || len(out.Changes) != 0 {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("project files changed")
|
||||
}
|
||||
original := get(t, root, "main.go")
|
||||
put(t, root, "main.go", original+"\n// manual edit\n")
|
||||
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
|
||||
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put(t, root, "main.go", original)
|
||||
if err := os.Remove(filepath.Join(root, "main.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before = snapshot(t, root)
|
||||
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
|
||||
t.Fatal("missing output accepted")
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("check wrote files")
|
||||
}
|
||||
out, err := Generate(Options{Dir: root, DryRun: true})
|
||||
if err != nil || len(out.Changes) != 1 {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("dry-run wrote files")
|
||||
}
|
||||
if _, err = Generate(Options{Dir: root}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if get(t, root, "main.go") != original {
|
||||
t.Fatal("missing output not restored")
|
||||
}
|
||||
if err = os.Remove(filepath.Join(root, "internal/run/run.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), "project-owned") {
|
||||
t.Fatal("silently recreated handwritten file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordedTemplateUpgrade(t *testing.T) {
|
||||
root := fresh(t)
|
||||
var m manifest
|
||||
if err := json.Unmarshal([]byte(get(t, root, manifestPath)), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := get(t, root, "main.go")
|
||||
older := original + "\n// previous generator baseline\n"
|
||||
put(t, root, "main.go", older)
|
||||
m.Generated["main.go"] = digest([]byte(older))
|
||||
data, _ := json.Marshal(m)
|
||||
put(t, root, manifestPath, string(data))
|
||||
before := snapshot(t, root)
|
||||
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
|
||||
t.Fatal("stale baseline accepted")
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("check mutated baseline")
|
||||
}
|
||||
if _, err := Generate(Options{Dir: root}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if get(t, root, "main.go") != original {
|
||||
t.Fatal("template was not updated")
|
||||
}
|
||||
}
|
||||
func TestProjectGuards(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, needle string
|
||||
mutate func(*testing.T, string)
|
||||
}{
|
||||
{"module", "module differs", func(t *testing.T, r string) {
|
||||
put(t, r, "go.mod", strings.Replace(get(t, r, "go.mod"), "example.com/program", "example.com/other", 1))
|
||||
}},
|
||||
{"oldFramework", "must require", func(t *testing.T, r string) {
|
||||
put(t, r, "go.mod", strings.ReplaceAll(get(t, r, "go.mod"), currentVersion, "v0.22.0-preview"))
|
||||
}},
|
||||
{"manifest", "unsupported", func(t *testing.T, r string) { put(t, r, manifestPath, `{"version":1,"kind":"api"}`) }},
|
||||
{"lock", "project.lock", func(t *testing.T, r string) { put(t, r, lockPath, "occupied") }},
|
||||
{"otherGenerator", "existing API", func(t *testing.T, r string) { put(t, r, ".goOmni/apiManifest.json", "{}") }},
|
||||
{"symlink", "symlink", func(t *testing.T, r string) {
|
||||
if e := os.Remove(filepath.Join(r, "main.go")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "outside.go")
|
||||
put(t, filepath.Dir(target), "outside.go", "outside")
|
||||
if e := os.Symlink(target, filepath.Join(r, "main.go")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := fresh(t)
|
||||
tc.mutate(t, root)
|
||||
before := snapshot(t, root)
|
||||
if _, err := Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), tc.needle) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("failed generation changed files")
|
||||
}
|
||||
})
|
||||
}
|
||||
root := fresh(t)
|
||||
before := snapshot(t, root)
|
||||
if _, err := New(Options{Dir: root}); err == nil {
|
||||
t.Fatal("new overwrote project")
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("new mutated project")
|
||||
}
|
||||
if _, err := Generate(Options{Dir: t.TempDir(), Check: true}); err == nil {
|
||||
t.Fatal("adopted unmanaged directory")
|
||||
}
|
||||
for _, version := range []string{"v0.27.0-preview", "latest", "v2.0.0", "v0.28.0-preview\nreplace bad"} {
|
||||
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), FrameworkVersion: version}); err == nil {
|
||||
t.Fatal("invalid version", version)
|
||||
}
|
||||
}
|
||||
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), Module: "example.com/a\nreplace bad"}); err == nil {
|
||||
t.Fatal("invalid module")
|
||||
}
|
||||
}
|
||||
6
cmd/go-omni/projectgen/templates/.gitignore.tmpl
Normal file
6
cmd/go-omni/projectgen/templates/.gitignore.tmpl
Normal file
@@ -0,0 +1,6 @@
|
||||
/program
|
||||
/var/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.goOmni/project.lock
|
||||
55
cmd/go-omni/projectgen/templates/README.md.tmpl
Normal file
55
cmd/go-omni/projectgen/templates/README.md.tmpl
Normal file
@@ -0,0 +1,55 @@
|
||||
# Go program
|
||||
|
||||
This project has no HTTP listener, routes, tasks or external dependencies configured.
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
go run . -check-config
|
||||
go run .
|
||||
# SIGINT/SIGTERM stops the program.
|
||||
go build -o program .
|
||||
go-omni project generate --dry-run
|
||||
go-omni project generate
|
||||
go-omni project check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Use the same fixed CLI and framework version. Local --framework-dir is only for
|
||||
explicit source development; normal creation pins the installed CLI version.
|
||||
|
||||
Edit internal/config/config.go (Config/Default/Validate), etc/config.yaml and
|
||||
internal/config/loader.go for optional decoders. JSON/YAML/XML resolve only
|
||||
explicit ${NAME} / ${NAME:-fallback}; fixed values are never implicitly overridden.
|
||||
-check-config validates without opening the logger or dependencies.
|
||||
|
||||
Add dependencies in internal/svc/serviceContext.go, registering OnClose immediately.
|
||||
Register all OnStop/OnDrain hooks in Init, before starting work. Paired hooks use the
|
||||
same name. OnStop stops acquisition; OnDrain returns nil only after all processing,
|
||||
acknowledgements and defers finish. Runtime executes stop, then drain, then resource
|
||||
cleanup in reverse registration order. Callbacks within a phase run concurrently;
|
||||
combine components with ordering requirements in a single hook. Accepted work uses
|
||||
WorkContext; Context is the early stop notification. No automatic retry or restart
|
||||
policy is installed. Adapt external components according to their actual semantics.
|
||||
|
||||
Write program behavior in internal/run/run.go. Return nil when a one-shot run finishes;
|
||||
return an error for fatal failure. The default Run waits for shutdown. Components may
|
||||
call RequestShutdown(err) to report fatal failure. Signal or Run completion starts
|
||||
shutdown; the entry waits for Run itself and registered background work. Do not make
|
||||
Run wait for resource cleanup: cleanup follows Run completion. Do not register hooks
|
||||
concurrently from Run. The drain name go-omni/project/run is reserved.
|
||||
|
||||
Run/Init panics are converted to startup/run failures and registered resources are
|
||||
cleaned safely. Panics in arbitrary project goroutines must be handled by the project.
|
||||
Unresponsive work is bounded by lifecycle timeouts: unfinished work causes
|
||||
ErrResourcesRetained, skips shared resource/logger cleanup and exits unsuccessfully.
|
||||
Go cannot forcibly stop a goroutine. SIGKILL/process crashes cannot run cleanup.
|
||||
|
||||
main.go, internal/config/loadGen.go and internal/svc/runtimeGen.go are generator-owned.
|
||||
Manual edits cause a conflict. Other starter files are project-owned and preserved;
|
||||
missing project-owned files require explicit restoration. Check is read-only and
|
||||
reports missing or stale generated files. It does not compile or execute project code.
|
||||
Commit .goOmni/projectManifest.json with the project. Generation uses a project lock,
|
||||
preflight checks and per-file atomic replacement with rollback for ordinary write errors;
|
||||
it is not crash-atomic across files. Avoid editing files during generation.
|
||||
|
||||
Full guide: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/PROGRAM.md
|
||||
9
cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl
Normal file
9
cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl
Normal file
@@ -0,0 +1,9 @@
|
||||
# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}.
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
output: stderr
|
||||
lifecycle:
|
||||
stopTimeout: 5s
|
||||
drainTimeout: 10s
|
||||
cancelTimeout: 5s
|
||||
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
// Config, Default and Validate are project-owned. Add project settings here.
|
||||
type Config struct {
|
||||
Log logx.Config `json:"log"`
|
||||
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||
}
|
||||
func Default() Config {
|
||||
log:=logx.Default();log.Service="PROJECT_MODULE"
|
||||
return Config{Log:log,Lifecycle:lifecycle.DefaultShutdownConfig()}
|
||||
}
|
||||
func(c Config)Validate()error {
|
||||
if err:=c.Log.Validate();err!=nil{return omniConfig.AtField("log",err)}
|
||||
if err:=c.Lifecycle.Validate();err!=nil{return omniConfig.AtField("lifecycle",err)}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
func Load(path string)(Config,error){
|
||||
loader,err:=NewLoader();if err!=nil{return Config{},err}
|
||||
return omniConfig.LoadProject(path,Default(),loader)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package config
|
||||
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
// NewLoader is project-owned. Register additional decoders here if needed.
|
||||
func NewLoader()(*omniConfig.Loader,error){return omniConfig.NewLoader()}
|
||||
13
cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl
Normal file
13
cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl
Normal file
@@ -0,0 +1,13 @@
|
||||
package run
|
||||
import (
|
||||
"context"
|
||||
"PROJECT_MODULE/internal/svc"
|
||||
)
|
||||
// Run is project-owned. Return nil for normal completion, or an error to fail.
|
||||
// The starter waits for shutdown; a one-shot program can simply return when done.
|
||||
// Register background stop/drain hooks in svc.Init, not concurrently here.
|
||||
// Accepted background work uses deps.WorkContext(), not the early-stop ctx.
|
||||
func Run(ctx context.Context,deps *svc.ServiceContext)error {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package svc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"PROJECT_MODULE/internal/config"
|
||||
)
|
||||
func New(ctx context.Context,logger *logx.Logger,cfg config.Config)(result *ServiceContext,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
|
||||
if logger==nil{return nil,errors.New("service context requires a logger")}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Lifecycle);if err!=nil{return nil,err}
|
||||
c:=&ServiceContext{Runtime:runtime,Config:cfg,Logger:logger}
|
||||
defer func(){
|
||||
if p:=recover();p!=nil{err=fmt.Errorf("dependency initialization panic: %v",p)}
|
||||
if err!=nil{result=nil;err=c.Finish(err)}
|
||||
}()
|
||||
if err=c.Init(c.Context());err!=nil{return nil,err}
|
||||
return c,nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package svc
|
||||
import (
|
||||
"context"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"PROJECT_MODULE/internal/config"
|
||||
)
|
||||
// ServiceContext is project-owned. Add dependencies without global variables.
|
||||
// Runtime owns registered resources; Logger is borrowed from the entry point.
|
||||
type ServiceContext struct {
|
||||
*lifecycle.Runtime
|
||||
Config config.Config
|
||||
Logger *logx.Logger
|
||||
}
|
||||
// Init runs before Run. Register resources immediately with OnClose.
|
||||
// Register paired OnStop/OnDrain hooks before exposing background work.
|
||||
// Use WorkContext for accepted work; Context is the early stop notification.
|
||||
// OnDrain returns nil only after processing and final cleanup have completed.
|
||||
// Complete all registrations here, before Run can race with shutdown.
|
||||
// The drain name "go-omni/project/run" is reserved for the generated entry.
|
||||
func(c *ServiceContext)Init(ctx context.Context)error{return nil}
|
||||
60
cmd/go-omni/projectgen/templates/main.go.tmpl
Normal file
60
cmd/go-omni/projectgen/templates/main.go.tmpl
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"PROJECT_MODULE/internal/config"
|
||||
program "PROJECT_MODULE/internal/run"
|
||||
"PROJECT_MODULE/internal/svc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path:=flag.String("config","etc/config.yaml","configuration file; empty uses Default()")
|
||||
check:=flag.Bool("check-config",false,"validate configuration without initializing dependencies")
|
||||
flag.Parse()
|
||||
if *check {if _,err:=config.Load(*path);err!=nil{log.Fatal(err)};return}
|
||||
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
|
||||
}
|
||||
|
||||
func run(ctx context.Context,path string)(runErr error) {
|
||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||
logger,err:=logx.New(cfg.Log);if err!=nil{return err}
|
||||
var deps *svc.ServiceContext
|
||||
defer func(){
|
||||
if p:=recover();p!=nil{runErr=fmt.Errorf("program initialization panic: %v",p)}
|
||||
if deps!=nil{runErr=deps.Finish(runErr)}
|
||||
// Unjoined work may still use its dependencies and logger. The process exits
|
||||
// unsuccessfully; resource retention never means the work completed.
|
||||
if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}
|
||||
}()
|
||||
deps,err=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
if deps.Context().Err()!=nil{return nil}
|
||||
done:=make(chan struct{})
|
||||
// This reserved drain proves Run has actually returned, including its defers.
|
||||
// Runtime bounds the wait even when Run ignores cancellation.
|
||||
deps.OnDrain("go-omni/project/run",func(context.Context)error{<-done;return nil})
|
||||
logger.InfoContext(deps.Context(),"program.starting")
|
||||
go func(){
|
||||
var result error
|
||||
returned:=false
|
||||
defer func(){
|
||||
if p:=recover();p!=nil{result=fmt.Errorf("program run panic: %v",p)} else if !returned{result=errors.New("program run exited without returning")}
|
||||
deps.RequestShutdown(result)
|
||||
close(done)
|
||||
}()
|
||||
result=program.Run(deps.Context(),deps)
|
||||
returned=true
|
||||
}()
|
||||
<-deps.Context().Done()
|
||||
return nil
|
||||
}
|
||||
283
cmd/go-omni/projectgen/testdata/consumer_test.go
vendored
Normal file
283
cmd/go-omni/projectgen/testdata/consumer_test.go
vendored
Normal file
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/plain/internal/config"
|
||||
program "example.com/plain/internal/run"
|
||||
"example.com/plain/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) {
|
||||
t.Helper()
|
||||
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
t.Cleanup(func() {
|
||||
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
})
|
||||
}
|
||||
func shortConfig(t *testing.T, logfile string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "config.yaml")
|
||||
text := "log:\n output: discard\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
||||
if logfile != "" {
|
||||
text = "log:\n file: " + logfile + "\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestFiniteRunAndCleanupOrder(t *testing.T) {
|
||||
setup(t)
|
||||
var events []string
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
events = append(events, "init")
|
||||
c.OnClose(func() error { events = append(events, "first"); return nil })
|
||||
c.OnClose(func() error { events = append(events, "second"); return nil })
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(context.Context, *svc.ServiceContext) error {
|
||||
defer func() { events = append(events, "run defer") }()
|
||||
events = append(events, "run")
|
||||
return nil
|
||||
}
|
||||
if err := run(context.Background(), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(events, []string{"init", "run", "run defer", "second", "first"}) {
|
||||
t.Fatal(events)
|
||||
}
|
||||
}
|
||||
func TestInitializationAndRunFailures(t *testing.T) {
|
||||
for _, stage := range []string{"init error", "init panic", "run error", "run panic", "run goexit", "component failure"} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
setup(t)
|
||||
boom := errors.New("failure marker")
|
||||
var closed atomic.Int32
|
||||
var started atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error { closed.Add(1); return nil })
|
||||
if stage == "init error" {
|
||||
return boom
|
||||
}
|
||||
if stage == "init panic" {
|
||||
panic(boom)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
started.Store(true)
|
||||
switch stage {
|
||||
case "run error":
|
||||
return boom
|
||||
case "run panic":
|
||||
panic(boom)
|
||||
case "run goexit":
|
||||
runtime.Goexit()
|
||||
case "component failure":
|
||||
c.RequestShutdown(boom)
|
||||
<-ctx.Done()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
err := run(context.Background(), "")
|
||||
if err == nil || closed.Load() != 1 {
|
||||
t.Fatal(stage, err, closed.Load())
|
||||
}
|
||||
if strings.HasPrefix(stage, "init") && started.Load() {
|
||||
t.Fatal("run started after failed initialization")
|
||||
}
|
||||
if strings.Contains(stage, "error") || stage == "component failure" {
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatal("failure cause lost", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestGracefulStopDrainsAcknowledgementBeforeClose(t *testing.T) {
|
||||
setup(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ready := make(chan struct{})
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var closed, acked atomic.Bool
|
||||
var canceledEarly atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error {
|
||||
if !acked.Load() {
|
||||
return errors.New("closed before acknowledgement")
|
||||
}
|
||||
closed.Store(true)
|
||||
return nil
|
||||
})
|
||||
c.OnStop("component", func(context.Context) error { close(stop); return nil })
|
||||
c.OnDrain("component", func(context.Context) error { <-done; return nil })
|
||||
go func() {
|
||||
defer close(done)
|
||||
<-stop
|
||||
if c.WorkContext().Err() != nil {
|
||||
canceledEarly.Store(true)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
acked.Store(true)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { close(ready); <-ctx.Done(); return nil }
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- run(ctx, "") }()
|
||||
<-ready
|
||||
cancel()
|
||||
if err := <-result; err != nil || !closed.Load() || canceledEarly.Load() {
|
||||
t.Fatal(err, closed.Load(), canceledEarly.Load())
|
||||
}
|
||||
}
|
||||
func TestInitializationFailureDrainsStartedWork(t *testing.T) {
|
||||
setup(t)
|
||||
done := make(chan struct{})
|
||||
stop := make(chan struct{})
|
||||
var closed atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error {
|
||||
select {
|
||||
case <-done:
|
||||
closed.Store(true)
|
||||
return nil
|
||||
default:
|
||||
return errors.New("resource closed while starting work still active")
|
||||
}
|
||||
})
|
||||
c.OnStop("partial", func(context.Context) error { close(stop); return nil })
|
||||
c.OnDrain("partial", func(context.Context) error { <-done; return nil })
|
||||
go func() { defer close(done); <-stop; time.Sleep(10 * time.Millisecond) }()
|
||||
return errors.New("later dependency failed")
|
||||
}
|
||||
if err := run(context.Background(), ""); err == nil || !closed.Load() {
|
||||
t.Fatal(err, closed.Load())
|
||||
}
|
||||
}
|
||||
func TestCancellationGraceJoinsWork(t *testing.T) {
|
||||
setup(t)
|
||||
done := make(chan struct{})
|
||||
var closed atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error { closed.Store(true); return nil })
|
||||
c.OnStop("cooperative", func(context.Context) error { return nil })
|
||||
c.OnDrain("cooperative", func(context.Context) error { <-done; return nil })
|
||||
go func() { <-c.WorkContext().Done(); close(done) }()
|
||||
return nil
|
||||
}
|
||||
err := run(context.Background(), shortConfig(t, ""))
|
||||
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) || !closed.Load() {
|
||||
t.Fatal(err, closed.Load())
|
||||
}
|
||||
}
|
||||
func TestUnjoinedWorkRetainsDependenciesAndLogger(t *testing.T) {
|
||||
for _, which := range []string{"run", "background", "init failure"} {
|
||||
t.Run(which, func(t *testing.T) {
|
||||
setup(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
release := make(chan struct{})
|
||||
joined := make(chan struct{})
|
||||
ready := make(chan struct{})
|
||||
var closed atomic.Bool
|
||||
var held *svc.ServiceContext
|
||||
logfile := filepath.Join(t.TempDir(), "retained.log")
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
held = c
|
||||
c.OnClose(func() error { closed.Store(true); return nil })
|
||||
if which != "run" {
|
||||
c.OnStop("stuck", func(context.Context) error { return nil })
|
||||
c.OnDrain("stuck", func(context.Context) error { <-joined; return nil })
|
||||
go func() { defer close(joined); <-release; c.Logger.Info("after shutdown deadline") }()
|
||||
}
|
||||
if which == "init failure" {
|
||||
return errors.New("initialization failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
close(ready)
|
||||
if which == "run" {
|
||||
defer close(joined)
|
||||
<-release
|
||||
c.Logger.Info("after shutdown deadline")
|
||||
} else {
|
||||
<-ctx.Done()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- run(ctx, shortConfig(t, logfile)) }()
|
||||
if which != "init failure" {
|
||||
<-ready
|
||||
cancel()
|
||||
}
|
||||
var err error
|
||||
select {
|
||||
case err = <-result:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown hung")
|
||||
}
|
||||
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
||||
t.Fatal(err, closed.Load())
|
||||
}
|
||||
close(release)
|
||||
<-joined
|
||||
// In the run case the entry's completion notification follows the user defer.
|
||||
// Close is idempotent and must retain resources even after late completion.
|
||||
if err = held.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
||||
t.Fatal("retention was lost", err)
|
||||
}
|
||||
data, _ := os.ReadFile(logfile)
|
||||
if !strings.Contains(string(data), "after shutdown deadline") {
|
||||
t.Fatal("logger closed while work still running", string(data))
|
||||
}
|
||||
_ = held.Logger.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestExplicitConfigurationAndPreflight(t *testing.T) {
|
||||
setup(t)
|
||||
t.Setenv("PLAIN_LEVEL", "debug")
|
||||
t.Setenv("LOG_LEVEL", "error")
|
||||
p := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(p, []byte("log:\n level: ${PLAIN_LEVEL}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil || cfg.Log.Level != "debug" {
|
||||
t.Fatal(cfg.Log.Level, err)
|
||||
}
|
||||
if err = os.WriteFile(p, []byte("log:\n level: info\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err = config.Load(p)
|
||||
if err != nil || cfg.Log.Level != "info" {
|
||||
t.Fatal("implicit environment override", err)
|
||||
}
|
||||
var initCalled atomic.Bool
|
||||
svc.Initialize = func(context.Context, *svc.ServiceContext) error { initCalled.Store(true); return nil }
|
||||
if err = os.WriteFile(p, []byte("lifecycle:\n drainTimeout: -1s\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = run(context.Background(), p); err == nil || initCalled.Load() {
|
||||
t.Fatal("invalid configuration initialized dependencies", err)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.27.0-preview"
|
||||
const defaultFrameworkVersion = "v0.28.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
Reference in New Issue
Block a user