feat: release v0.28.1 mixed API and generic project workspaces
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# go-omni CLI
|
||||
|
||||
v0.28.1 的 `workspace generate/check` 支持 API 与通用工程混合处理。每个 services 项可指定 `kind: api | project`,省略时按工程清单识别,旧 API 配置继续有效。format 只处理 API 契约,通用工程显示 SKIP;check 只读并汇总各工程失败。见 [可运行示例](../../examples/mixedWorkspace/README.md)。
|
||||
|
||||
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 与框架。
|
||||
@@ -18,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -53,14 +55,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.28.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.28.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
当前模板与依赖以 v0.28.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
当前模板与依赖以 v0.28.1-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -23,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.28.0-preview"
|
||||
const currentVersion = "v0.28.1-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
|
||||
@@ -3,15 +3,18 @@ package apigen
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
type WorkspaceService struct {
|
||||
Dir string `json:"dir"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
API string `json:"api,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
@@ -19,10 +22,14 @@ type WorkspaceConfig struct {
|
||||
Services []WorkspaceService `json:"services"`
|
||||
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
|
||||
}
|
||||
type workspaceTarget struct {
|
||||
dir, kind, module, entry string
|
||||
err error
|
||||
}
|
||||
|
||||
// Workspace operates independent Go modules listed in an explicit local file.
|
||||
// All projects are preflighted before generate/format writes. Each generator
|
||||
// transaction is atomic within its project; no cross-module transaction is promised.
|
||||
// Workspace dispatches to each independent project's generator. Directory/schema
|
||||
// validation precedes any providers; all targets are preflighted before writes.
|
||||
// Transactions remain per-project, not crash-atomic across the workspace.
|
||||
func Workspace(path, command string, w io.Writer) error {
|
||||
if command != "generate" && command != "check" && command != "format" {
|
||||
return errors.New("workspace command must be generate, check or format")
|
||||
@@ -53,8 +60,8 @@ func Workspace(path, command string, w io.Writer) error {
|
||||
return errors.New("workspace must list 1..64 services")
|
||||
}
|
||||
root := canonical(filepath.Dir(real))
|
||||
var options []Options
|
||||
seen := map[string]bool{}
|
||||
// Validate the entire workspace boundary before resolving or running any target.
|
||||
for _, service := range cfg.Services {
|
||||
if service.Dir == "" || service.Dir == "." {
|
||||
return errors.New("workspace requires distinct relative service directories")
|
||||
@@ -62,27 +69,26 @@ func Workspace(path, command string, w io.Writer) error {
|
||||
if err = safePath(root, service.Dir+"/.goOmniWorkspaceCheck"); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(root, filepath.FromSlash(service.Dir))
|
||||
key := strings.ToLower(dir)
|
||||
key := strings.ToLower(filepath.Join(root, filepath.FromSlash(service.Dir)))
|
||||
for prior := range seen {
|
||||
if prior == key || strings.HasPrefix(key, prior+string(filepath.Separator)) || strings.HasPrefix(prior, key+string(filepath.Separator)) {
|
||||
return errors.New("workspace service directories overlap")
|
||||
}
|
||||
}
|
||||
seen[key] = true
|
||||
entry := service.API
|
||||
if entry == "" {
|
||||
entry, err = ProjectEntry(dir)
|
||||
} else {
|
||||
if err = safePath(dir, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
targets := make([]workspaceTarget, 0, len(cfg.Services))
|
||||
for _, service := range cfg.Services {
|
||||
t := workspaceTarget{dir: filepath.Join(root, filepath.FromSlash(service.Dir)), module: service.Module}
|
||||
t.kind, t.err = workspaceKind(t.dir, service.Kind, service.API)
|
||||
if t.err == nil && t.kind == "api" {
|
||||
if service.API == "" {
|
||||
t.entry, t.err = ProjectEntry(t.dir)
|
||||
} else if t.err = safePath(t.dir, service.API); t.err == nil {
|
||||
t.entry = filepath.Join(t.dir, filepath.FromSlash(service.API))
|
||||
}
|
||||
entry = filepath.Join(dir, entry)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
|
||||
targets = append(targets, t)
|
||||
}
|
||||
var catalogs map[string][]byte
|
||||
if command != "format" {
|
||||
@@ -90,26 +96,41 @@ func Workspace(path, command string, w io.Writer) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range options {
|
||||
options[i].documentCatalogs = catalogs
|
||||
}
|
||||
}
|
||||
var failures error
|
||||
for _, o := range options {
|
||||
if command == "format" {
|
||||
spec, e := Parse(o.API)
|
||||
if e == nil {
|
||||
_, e = Compile(spec)
|
||||
}
|
||||
err = e
|
||||
report := func(t workspaceTarget, e error) {
|
||||
if e != nil {
|
||||
failures = errors.Join(failures, fmt.Errorf("%s: %w", t.dir, e))
|
||||
fmt.Fprintf(w, "FAIL %s: %v\n", t.dir, e)
|
||||
} else if command == "format" && t.kind == "project" {
|
||||
fmt.Fprintf(w, "SKIP %s (project has no API contract to format)\n", t.dir)
|
||||
} else {
|
||||
_, err = Generate(o)
|
||||
fmt.Fprintf(w, "OK %s\n", t.dir)
|
||||
}
|
||||
if err != nil {
|
||||
failures = errors.Join(failures, fmt.Errorf("%s: %w", o.Dir, err))
|
||||
}
|
||||
if command == "check" {
|
||||
// Each generator checks its own manifest and managed outputs. An invalid
|
||||
// project must not suppress diagnostics for another independent project.
|
||||
for _, t := range targets {
|
||||
report(t, runWorkspaceTarget(t, command, false, catalogs))
|
||||
}
|
||||
return failures
|
||||
}
|
||||
for i := range targets {
|
||||
t := &targets[i]
|
||||
t.err = runWorkspaceTarget(*t, command, true, catalogs)
|
||||
}
|
||||
for _, t := range targets {
|
||||
if t.err != nil {
|
||||
report(t, t.err)
|
||||
}
|
||||
}
|
||||
if failures != nil {
|
||||
for _, t := range targets {
|
||||
if t.err == nil {
|
||||
fmt.Fprintf(w, "SKIP %s (workspace preflight failed; no changes applied)\n", t.dir)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
if command == "generate" {
|
||||
@@ -119,26 +140,79 @@ func Workspace(path, command string, w io.Writer) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range options {
|
||||
switch command {
|
||||
case "format":
|
||||
_, err = FormatProject(o.API, false)
|
||||
case "generate":
|
||||
o.DryRun = false
|
||||
_, err = Generate(o)
|
||||
case "check":
|
||||
_, fmtErr := FormatProject(o.API, true)
|
||||
o.DryRun = false
|
||||
o.Check = true
|
||||
_, genErr := Generate(o)
|
||||
err = errors.Join(fmtErr, genErr)
|
||||
}
|
||||
if err != nil {
|
||||
failures = errors.Join(failures, fmt.Errorf("%s: %w", o.Dir, err))
|
||||
fmt.Fprintf(w, "FAIL %s\n", o.Dir)
|
||||
} else {
|
||||
fmt.Fprintf(w, "OK %s\n", o.Dir)
|
||||
}
|
||||
for _, t := range targets {
|
||||
report(t, runWorkspaceTarget(t, command, false, catalogs))
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
// An explicit API entry remains supported before the first API generation.
|
||||
// Without manifests, omitted kind preserves the previous API interpretation.
|
||||
// Manifests of both kinds or a conflicting declaration never silently choose one.
|
||||
func workspaceKind(dir, kind, entry string) (string, error) {
|
||||
if kind != "" && kind != "api" && kind != "project" {
|
||||
return "", fmt.Errorf("unknown workspace kind %q; expected api or project", kind)
|
||||
}
|
||||
detected := ""
|
||||
for _, candidate := range []struct{ kind, path string }{{"api", manifestPath}, {"project", ".goOmni/projectManifest.json"}} {
|
||||
if err := safePath(dir, candidate.path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, candidate.path)); err == nil {
|
||||
if detected != "" {
|
||||
return "", errors.New("ambiguous project: both API and project manifests exist")
|
||||
}
|
||||
detected = candidate.kind
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if kind != "" && detected != "" && kind != detected {
|
||||
return "", fmt.Errorf("workspace kind %q conflicts with %s manifest", kind, detected)
|
||||
}
|
||||
if kind == "" {
|
||||
kind = detected
|
||||
}
|
||||
if kind == "" {
|
||||
kind = "api"
|
||||
}
|
||||
if kind == "project" && entry != "" {
|
||||
return "", errors.New("workspace project kind does not accept an api entry")
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
func runWorkspaceTarget(t workspaceTarget, command string, preview bool, catalogs map[string][]byte) error {
|
||||
if t.err != nil {
|
||||
return t.err
|
||||
}
|
||||
if t.kind == "project" {
|
||||
if command == "format" {
|
||||
return nil
|
||||
}
|
||||
_, err := projectgen.Generate(projectgen.Options{Dir: t.dir, Module: t.module, DryRun: preview, Check: command == "check"})
|
||||
return err
|
||||
}
|
||||
o := Options{API: t.entry, Dir: t.dir, Module: t.module, DryRun: preview, documentCatalogs: catalogs}
|
||||
switch command {
|
||||
case "format":
|
||||
if preview {
|
||||
spec, err := Parse(t.entry)
|
||||
if err == nil {
|
||||
_, err = Compile(spec)
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err := FormatProject(t.entry, false)
|
||||
return err
|
||||
case "generate":
|
||||
_, err := Generate(o)
|
||||
return err
|
||||
case "check":
|
||||
_, formatErr := FormatProject(t.entry, true)
|
||||
o.Check = true
|
||||
_, generateErr := Generate(o)
|
||||
return errors.Join(formatErr, generateErr)
|
||||
}
|
||||
return errors.New("unsupported workspace command")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -30,8 +31,11 @@ func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitio
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`)
|
||||
}
|
||||
if _, err := projectgen.New(projectgen.Options{Dir: filepath.Join(root, "services/program"), Module: "example.com/program", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(root, "omni.workspace.json")
|
||||
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"}]}`)
|
||||
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"},{"dir":"services/program"}]}`)
|
||||
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
226
cmd/go-omni/apigen/workspaceMixed_test.go
Normal file
226
cmd/go-omni/apigen/workspaceMixed_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||
)
|
||||
|
||||
func mixedWorkspace(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if _, err := New(Options{Dir: filepath.Join(root, "apps/interface"), Module: "example.com/interface", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := projectgen.New(projectgen.Options{Dir: filepath.Join(root, "apps/program"), Module: "example.com/program", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := filepath.Join(root, "omni.workspace.json")
|
||||
writeFile(t, config, `{"services":[{"dir":"apps/interface"},{"dir":"apps/program"}]}`)
|
||||
if err := Workspace(config, "format", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Workspace(config, "generate", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, config
|
||||
}
|
||||
func TestWorkspaceMixedKindsAndReadOnlyCheck(t *testing.T) {
|
||||
for _, explicit := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "inferred", true: "explicit"}[explicit], func(t *testing.T) {
|
||||
root, config := mixedWorkspace(t)
|
||||
if explicit {
|
||||
writeFile(t, config, `{"services":[{"dir":"apps/interface","kind":"api","module":"example.com/interface"},{"dir":"apps/program","kind":"project","module":"example.com/program"}]}`)
|
||||
}
|
||||
// Workspaces do not manage the Go toolchain's workspace or dependency files.
|
||||
writeFile(t, filepath.Join(root, "go.work"), "go 1.22.0\nuse (\n ./apps/interface\n ./apps/program\n)\n")
|
||||
writeFile(t, filepath.Join(root, "go.work.sum"), "workspace sums marker\n")
|
||||
for _, dir := range []string{"apps/interface", "apps/program"} {
|
||||
writeFile(t, filepath.Join(root, dir, "go.sum"), "module sums marker\n")
|
||||
}
|
||||
runFile := filepath.Join(root, "apps/program/internal/run/run.go")
|
||||
writeFile(t, runFile, readFile(t, runFile)+"\n// project-owned work\n")
|
||||
before := snapshot(t, root)
|
||||
for _, op := range []string{"check", "generate", "check"} {
|
||||
var output bytes.Buffer
|
||||
if err := Workspace(config, op, &output); err != nil {
|
||||
t.Fatal(op, err)
|
||||
}
|
||||
if strings.Count(output.String(), "OK ") != 2 {
|
||||
t.Fatal(output.String())
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal(op, "changed clean workspace or project-owned files")
|
||||
}
|
||||
}
|
||||
// Formatting only touches API contracts, even when project Go has drift.
|
||||
main := filepath.Join(root, "apps/program/main.go")
|
||||
writeFile(t, main, readFile(t, main)+"\n// drift intentionally not formatted\n")
|
||||
before = snapshot(t, root)
|
||||
var output bytes.Buffer
|
||||
if err := Workspace(config, "format", &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "SKIP ") || !strings.Contains(output.String(), "apps/program") {
|
||||
t.Fatal(output.String())
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("format changed a plain Go project")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestWorkspaceMixedFailureAggregation(t *testing.T) {
|
||||
root, config := mixedWorkspace(t)
|
||||
for _, dir := range []string{"apps/interface", "apps/program"} {
|
||||
path := filepath.Join(root, dir, "main.go")
|
||||
writeFile(t, path, readFile(t, path)+"\n// manual drift\n")
|
||||
}
|
||||
before := snapshot(t, root)
|
||||
var output bytes.Buffer
|
||||
err := Workspace(config, "check", &output)
|
||||
if err == nil || !strings.Contains(err.Error(), "apps/interface") || !strings.Contains(err.Error(), "apps/program") || strings.Count(output.String(), "FAIL ") != 2 {
|
||||
t.Fatal(err, output.String())
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("failed check wrote files")
|
||||
}
|
||||
// Identity failures also keep unrelated project diagnostics.
|
||||
writeFile(t, config, `{"services":[{"dir":"apps/interface","kind":"project"},{"dir":"apps/program"}]}`)
|
||||
output.Reset()
|
||||
before = snapshot(t, root)
|
||||
err = Workspace(config, "check", &output)
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicts") || strings.Count(output.String(), "FAIL ") != 2 {
|
||||
t.Fatal(err, output.String())
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("identity failure changed workspace")
|
||||
}
|
||||
}
|
||||
func TestWorkspaceMixedPreflightBeforeAnyWrites(t *testing.T) {
|
||||
for _, broken := range []string{"apps/interface", "apps/program"} {
|
||||
t.Run(broken, func(t *testing.T) {
|
||||
root, config := mixedWorkspace(t)
|
||||
stale := "apps/interface"
|
||||
if stale == broken {
|
||||
stale = "apps/program"
|
||||
}
|
||||
// The first valid generator could repair its missing output, but must not
|
||||
// write if another project's ownership check will fail.
|
||||
if err := os.Remove(filepath.Join(root, stale, "main.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, broken, "main.go")
|
||||
writeFile(t, path, readFile(t, path)+"\n// manual change\n")
|
||||
before := snapshot(t, root)
|
||||
var output bytes.Buffer
|
||||
if err := Workspace(config, "generate", &output); err == nil {
|
||||
t.Fatal("bad preflight accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("partial regeneration before failed preflight")
|
||||
}
|
||||
if !strings.Contains(output.String(), "SKIP ") || !strings.Contains(output.String(), "FAIL ") {
|
||||
t.Fatal(output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
root, config := mixedWorkspace(t)
|
||||
for _, dir := range []string{"apps/interface", "apps/program"} {
|
||||
if err := os.Remove(filepath.Join(root, dir, "main.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := Workspace(config, "generate", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Workspace(config, "check", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestWorkspaceKindGuardsAndLegacyEntry(t *testing.T) {
|
||||
root, config := mixedWorkspace(t)
|
||||
for _, tc := range []struct{ services, needle string }{
|
||||
{`[{"dir":"apps/program","kind":"worker"}]`, "unknown workspace kind"},
|
||||
{`[{"dir":"apps/program","kind":"api"}]`, "conflicts"},
|
||||
{`[{"dir":"apps/interface","kind":"project"}]`, "conflicts"},
|
||||
{`[{"dir":"apps/program","api":"api/service.api"}]`, "does not accept"},
|
||||
{`[{"dir":"apps/program","kind":"project","api":"api/service.api"}]`, "does not accept"},
|
||||
{`[{"dir":"apps/program","module":"example.com/other"}]`, "module differs"},
|
||||
{`[{"dir":"../outside","kind":"project"}]`, "unsafe"},
|
||||
{`[{"dir":"apps/program"},{"dir":"apps/program/nested","kind":"project"}]`, "overlap"},
|
||||
{`[{"dir":"apps/program","kind":"project","kind":"api"}]`, "duplicate"},
|
||||
} {
|
||||
writeFile(t, config, `{"services":`+tc.services+`}`)
|
||||
before := snapshot(t, root)
|
||||
err := Workspace(config, "check", io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.needle) {
|
||||
t.Fatal(tc.services, err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("invalid kind wrote files")
|
||||
}
|
||||
}
|
||||
writeFile(t, config, `{"services":[{"dir":"apps/program"}]}`)
|
||||
other := filepath.Join(root, "apps/program/.goOmni/apiManifest.json")
|
||||
writeFile(t, other, "{}")
|
||||
if err := Workspace(config, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "both API and project") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A malformed manifest must be routed to its owner, never fall back to API.
|
||||
writeFile(t, filepath.Join(root, "apps/program/.goOmni/projectManifest.json"), "invalid")
|
||||
if err := Workspace(config, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "invalid project manifest") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Explicit API entry outside a managed project preserves the original path.
|
||||
legacy := filepath.Join(root, "legacy")
|
||||
writeFile(t, filepath.Join(legacy, "service.api"), "service legacy {\n@handler ping\nget /ping\n}\n")
|
||||
writeFile(t, config, `{"services":[{"dir":"legacy","api":"service.api","module":"example.com/legacy"}]}`)
|
||||
if err := Workspace(config, "generate", io.Discard); err != nil {
|
||||
t.Fatal("legacy API entry", err)
|
||||
}
|
||||
}
|
||||
func TestWorkspaceManifestSymlinkRejected(t *testing.T) {
|
||||
root, config := mixedWorkspace(t)
|
||||
path := filepath.Join(root, "apps/program/.goOmni/projectManifest.json")
|
||||
target := filepath.Join(t.TempDir(), "manifest.json")
|
||||
writeFile(t, target, readFile(t, path))
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := snapshot(t, root)
|
||||
for _, op := range []string{"check", "format", "generate"} {
|
||||
if err := Workspace(config, op, io.Discard); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatal(op, err)
|
||||
}
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("followed symlink")
|
||||
}
|
||||
}
|
||||
func TestWorkspaceProjectOnly(t *testing.T) {
|
||||
root, config := mixedWorkspace(t)
|
||||
raw, _ := json.Marshal(WorkspaceConfig{Services: []WorkspaceService{{Dir: "apps/program"}}})
|
||||
writeFile(t, config, string(raw))
|
||||
before := snapshot(t, root)
|
||||
for _, op := range []string{"format", "generate", "check"} {
|
||||
if err := Workspace(config, op, io.Discard); err != nil {
|
||||
t.Fatal(op, err)
|
||||
}
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("plain workspace changed")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const currentVersion = "v0.28.0-preview"
|
||||
const currentVersion = "v0.28.1-preview"
|
||||
const minimumVersion = "v0.28.0-preview"
|
||||
const manifestPath = ".goOmni/projectManifest.json"
|
||||
const lockPath = ".goOmni/project.lock"
|
||||
|
||||
@@ -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.28.0-preview"
|
||||
const defaultFrameworkVersion = "v0.28.1-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.
|
||||
|
||||
162
cmd/go-omni/workspace_test.go
Normal file
162
cmd/go-omni/workspace_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The same consumer is exercised with a source-built CLI before release and a
|
||||
// downloaded CLI after release. Project modules are always independently built.
|
||||
func TestMixedWorkspaceConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
root := t.TempDir()
|
||||
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
local := cli == ""
|
||||
framework, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if local {
|
||||
cli = filepath.Join(t.TempDir(), "go-omni")
|
||||
workspaceCommand(t, "", nil, false, "go", "build", "-o", cli, ".")
|
||||
}
|
||||
modulePath := "git.yuntuo.hk/k2safe/go-omni"
|
||||
for _, kind := range []string{"api", "project"} {
|
||||
dir := filepath.Join(root, kind)
|
||||
args := []string{kind, "new", dir, "--module", "example.com/" + kind}
|
||||
if local {
|
||||
args = append(args, "--framework-dir", framework)
|
||||
}
|
||||
workspaceCommand(t, root, nil, false, cli, args...)
|
||||
workspaceCommand(t, dir, nil, false, "go", "mod", "tidy")
|
||||
data := workspaceCommand(t, dir, nil, false, "go", "list", "-m", "-json", modulePath)
|
||||
if !local {
|
||||
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("consumer is not a fixed remote dependency", dep)
|
||||
}
|
||||
t.Logf("%s remote framework %s checksum %s; no local replace", kind, dep.Version, dep.Sum)
|
||||
}
|
||||
}
|
||||
write := func(name, data string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte(data), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("omni.workspace.json", `{"services":[{"dir":"api","kind":"api"},{"dir":"project","kind":"project"}]}`)
|
||||
for _, op := range []string{"format", "generate", "check"} {
|
||||
output := workspaceCommand(t, root, nil, false, cli, "workspace", op)
|
||||
if op == "format" && !strings.Contains(output, "SKIP ") {
|
||||
t.Fatal("missing project format skip", output)
|
||||
}
|
||||
}
|
||||
// Run the actual Go multi-module toolchain independently of generation.
|
||||
workspaceCommand(t, root, nil, false, "go", "work", "init", "./api", "./project")
|
||||
work, err := filepath.EvalSymlinks(filepath.Join(root, "go.work"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspaceCommand(t, root, []string{"GOWORK=" + work}, false, "go", "test", "-race", "./api/...", "./project/...")
|
||||
workspaceCommand(t, root, []string{"GOWORK=" + work}, false, "go", "build", "./api/...", "./project/...")
|
||||
before := workspaceSnapshot(t, root)
|
||||
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
|
||||
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
|
||||
t.Fatal("check modified Go workspace/module files")
|
||||
}
|
||||
// Omitted kinds discover both released manifests as well.
|
||||
write("omni.workspace.json", `{"services":[{"dir":"api"},{"dir":"project"}]}`)
|
||||
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
|
||||
originals := map[string]string{}
|
||||
for _, kind := range []string{"api", "project"} {
|
||||
name := kind + "/main.go"
|
||||
data, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originals[name] = string(data)
|
||||
write(name, string(data)+"\n// intentional drift\n")
|
||||
}
|
||||
before = workspaceSnapshot(t, root)
|
||||
output := workspaceCommand(t, root, nil, true, cli, "workspace", "check")
|
||||
if strings.Count(output, "FAIL ") != 2 {
|
||||
t.Fatal("did not aggregate both projects", output)
|
||||
}
|
||||
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
|
||||
t.Fatal("failed check wrote files")
|
||||
}
|
||||
// The healthy project's missing output must stay missing when another fails.
|
||||
write("api/main.go", originals["api/main.go"])
|
||||
if err := os.Remove(filepath.Join(root, "api/main.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before = workspaceSnapshot(t, root)
|
||||
workspaceCommand(t, root, nil, true, cli, "workspace", "generate")
|
||||
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
|
||||
t.Fatal("partial workspace generation")
|
||||
}
|
||||
write("project/main.go", originals["project/main.go"])
|
||||
workspaceCommand(t, root, nil, false, cli, "workspace", "generate")
|
||||
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
|
||||
}
|
||||
func workspaceCommand(t *testing.T, dir string, extra []string, wantFailure bool, 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(append(os.Environ(), "GOWORK=off"), extra...)
|
||||
data, err := cmd.CombinedOutput()
|
||||
if wantFailure {
|
||||
exit, ok := err.(*exec.ExitError)
|
||||
if !ok || exit.ExitCode() != 1 {
|
||||
t.Fatalf("expected exit 1 from %s %v: %v\n%s", name, args, err, data)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, data)
|
||||
}
|
||||
t.Logf("%s %v\n%s", name, args, data)
|
||||
return string(data)
|
||||
}
|
||||
func workspaceSnapshot(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, e := filepath.Rel(root, path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
files[rel] = string(data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
Reference in New Issue
Block a user