diff --git a/.github/workflows/update-api-go.yml b/.github/workflows/update-api-go.yml new file mode 100644 index 000000000..3a6b4ef62 --- /dev/null +++ b/.github/workflows/update-api-go.yml @@ -0,0 +1,108 @@ +name: Update go.temporal.io/api + +# Bumps the go.temporal.io/api (temporalio/api-go) dependency to the latest main +# pseudo-version, regenerates code, and opens a PR — but only when regeneration +# actually changes tracked *.go files (a bare go.mod/go.sum churn is discarded). +# +# Intended to be triggered by temporalio/api-go on every merge to its main branch +# (mirrors how temporalio/api triggers api-go's update-proto.yml). Can also be run +# by hand from the Actions tab. + +on: + workflow_dispatch: + inputs: + commit_author: + description: "Commit author username" + required: false + default: temporal-cicd + commit_author_email: + description: "Commit author email" + required: false + default: temporal-cicd@users.noreply.github.com + source_sha: + description: "api-go commit SHA that triggered this update (for the PR body)" + required: false + default: "" + +permissions: + contents: read + +# At most one bump in flight; a newer api-go merge supersedes an in-progress run. +concurrency: + group: update-api-go + cancel-in-progress: true + +jobs: + update: + # Guard against running in forks + if: github.repository == 'temporalio/cli' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ secrets.TEMPORAL_CICD_APP_ID }} + private-key: ${{ secrets.TEMPORAL_CICD_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.generate_token.outputs.token }} + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Bump go.temporal.io/api and regenerate + run: | + go get go.temporal.io/api@main + go mod tidy + make gen + # Compile sanity-check; the full cross-platform matrix runs on the PR's CI. + go build ./... + + - name: Detect Go source changes + id: changes + run: | + # Only a *.go change is worth a PR; a lone go.mod/go.sum pseudo-version + # bump is noise and gets reverted so the working tree is clean. + if [ -n "$(git diff --name-only -- '*.go')" ]; then + echo "go_changed=true" >> "$GITHUB_OUTPUT" + echo "Changed Go files:" + git diff --name-only -- '*.go' + else + echo "go_changed=false" >> "$GITHUB_OUTPUT" + echo "No *.go changes from the api-go bump; discarding go.mod/go.sum churn." + git checkout -- . + fi + + - name: Create pull request + if: steps.changes.outputs.go_changed == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.generate_token.outputs.token }} + branch: auto/update-api-go + delete-branch: true + commit-message: "Update go.temporal.io/api to latest main" + author: ${{ github.event.inputs.commit_author }} <${{ github.event.inputs.commit_author_email }}> + committer: ${{ github.event.inputs.commit_author }} <${{ github.event.inputs.commit_author_email }}> + title: "Update go.temporal.io/api to latest main" + body: | + Automated bump of `go.temporal.io/api` to the latest `main` + pseudo-version, with regenerated code (`make gen`). + + Triggered by api-go commit: ${{ github.event.inputs.source_sha || 'manual run' }} + + Opened because regeneration changed tracked `*.go` files. Please review + and, if needed, fold in coordinated `go.temporal.io/sdk` / `server` bumps + before merging. + labels: dependencies + reviewers: | + spkane31 + chaptersix diff --git a/Makefile b/Makefile index 56e79a663..3340083c9 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ all: gen build -gen: internal/temporalcli/commands.gen.go cliext/flags.gen.go +gen: internal/temporalcli/commands.gen.go cliext/flags.gen.go internal/temporalcli/commands.system_nexus.gen.go internal/temporalcli/commands.gen.go: internal/temporalcli/commands.yaml go run ./cmd/gen-commands \ @@ -15,6 +15,11 @@ cliext/flags.gen.go: cliext/option-sets.yaml -input cliext/option-sets.yaml \ -pkg cliext > $@ +internal/temporalcli/commands.system_nexus.gen.go: + go run ./cmd/gen-system-nexus \ + -pkg temporalcli \ + -package go.temporal.io/api/workflowservice/v1/workflowservicenexus > $@ + gen-docs: internal/temporalcli/commands.yaml cliext/option-sets.yaml go run ./cmd/gen-docs \ -input internal/temporalcli/commands.yaml \ diff --git a/cmd/gen-system-nexus/main.go b/cmd/gen-system-nexus/main.go new file mode 100644 index 000000000..e6f942307 --- /dev/null +++ b/cmd/gen-system-nexus/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/temporalio/cli/internal/systemnexusgen" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +type stringSlice []string + +func (s *stringSlice) String() string { return strings.Join(*s, ",") } +func (s *stringSlice) Set(v string) error { + *s = append(*s, v) + return nil +} + +func run() error { + var ( + pkg string + packagePaths stringSlice + ) + flag.StringVar(&pkg, "pkg", "temporalcli", "Package name for generated code") + flag.Var(&packagePaths, "package", "Nexus service import path to scan (can be specified multiple times)") + flag.Parse() + + if len(packagePaths) == 0 { + return fmt.Errorf("-package flag is required") + } + + ops, err := systemnexusgen.Parse(packagePaths...) + if err != nil { + return fmt.Errorf("failed parsing nexus packages: %w", err) + } + + b, err := systemnexusgen.GenerateCode(pkg, ops) + if err != nil { + return fmt.Errorf("failed generating code: %w", err) + } + + if _, err := os.Stdout.Write(b); err != nil { + return fmt.Errorf("failed writing output: %w", err) + } + return nil +} diff --git a/internal/systemnexusgen/code.go b/internal/systemnexusgen/code.go new file mode 100644 index 000000000..90bd80708 --- /dev/null +++ b/internal/systemnexusgen/code.go @@ -0,0 +1,130 @@ +package systemnexusgen + +import ( + "bytes" + "fmt" + "go/format" + "text/template" +) + +// GenerateCode renders the systemNexusOps map for the given operations into a +// Go source file belonging to package pkgName. +func GenerateCode(pkgName string, ops []Operation) ([]byte, error) { + im := newImportManager() + protoAlias := im.add("google.golang.org/protobuf/proto", "proto") + + type entry struct { + NexusAlias string + ServiceVar string + OpField string + ReqAlias string + ReqName string + RespAlias string + RespName string + } + + entries := make([]entry, 0, len(ops)) + for _, op := range ops { + entries = append(entries, entry{ + NexusAlias: im.add(op.NexusPkgPath, op.NexusPkgName), + ServiceVar: op.ServiceVarName, + OpField: op.OpFieldName, + ReqAlias: im.add(op.Request.PkgPath, op.Request.PkgName), + ReqName: op.Request.Name, + RespAlias: im.add(op.Response.PkgPath, op.Response.PkgName), + RespName: op.Response.Name, + }) + } + + data := struct { + Package string + ProtoAlias string + Imports []importEntry + Entries []entry + }{ + Package: pkgName, + ProtoAlias: protoAlias, + Imports: im.entries(), + Entries: entries, + } + + var buf bytes.Buffer + if err := fileTemplate.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("rendering template: %w", err) + } + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("formatting generated source: %w\n%s", err, buf.String()) + } + return formatted, nil +} + +var fileTemplate = template.Must(template.New("file").Parse(`// Code generated by gen-system-nexus. DO NOT EDIT. + +package {{.Package}} + +import ( +{{- range .Imports}} + {{if .Aliased}}{{.Alias}} {{end}}"{{.Path}}" +{{- end}} +) + +// systemNexusOps is the generated registry of known system Nexus operations on +// the __temporal_system endpoint. Regenerate with 'make gen' after bumping the +// go.temporal.io/api dependency. +var systemNexusOps = map[systemNexusOpKey]systemNexusOpTypes{ +{{- range .Entries}} + { + Endpoint: temporalSystemNexusEndpoint, + Operation: {{.NexusAlias}}.{{.ServiceVar}}.{{.OpField}}.Name(), + }: { + NewRequest: func() {{$.ProtoAlias}}.Message { return &{{.ReqAlias}}.{{.ReqName}}{} }, + NewResponse: func() {{$.ProtoAlias}}.Message { return &{{.RespAlias}}.{{.RespName}}{} }, + }, +{{- end}} +} +`)) + +// importEntry is a single import line in the generated file. +type importEntry struct { + Path string + Alias string + Aliased bool // true when Alias differs from the package's natural name +} + +// importManager assigns collision-free aliases to imported packages, preferring +// each package's natural name. +type importManager struct { + byPath map[string]*importEntry + used map[string]bool + order []string +} + +func newImportManager() *importManager { + return &importManager{byPath: map[string]*importEntry{}, used: map[string]bool{}} +} + +// add registers a package path with its natural name and returns the alias to +// use when referencing it in generated code. +func (m *importManager) add(path, name string) string { + if e, ok := m.byPath[path]; ok { + return e.Alias + } + alias := name + for i := 2; m.used[alias]; i++ { + alias = fmt.Sprintf("%s%d", name, i) + } + e := &importEntry{Path: path, Alias: alias, Aliased: alias != name} + m.byPath[path] = e + m.used[alias] = true + m.order = append(m.order, path) + return alias +} + +func (m *importManager) entries() []importEntry { + out := make([]importEntry, 0, len(m.order)) + for _, p := range m.order { + out = append(out, *m.byPath[p]) + } + return out +} diff --git a/internal/systemnexusgen/code_test.go b/internal/systemnexusgen/code_test.go new file mode 100644 index 000000000..de24a0aed --- /dev/null +++ b/internal/systemnexusgen/code_test.go @@ -0,0 +1,47 @@ +package systemnexusgen_test + +import ( + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/temporalio/cli/internal/systemnexusgen" +) + +func TestGenerateCode(t *testing.T) { + ops := []systemnexusgen.Operation{ + { + NexusPkgPath: "go.temporal.io/api/workflowservice/v1/workflowservicenexus", + NexusPkgName: "workflowservicenexus", + ServiceVarName: "TemporalAPIWorkflowserviceV1WorkflowService", + OpFieldName: "SignalWithStartWorkflowExecution", + Request: systemnexusgen.ProtoType{ + PkgPath: "go.temporal.io/api/workflowservice/v1", + PkgName: "workflowservice", + Name: "SignalWithStartWorkflowExecutionRequest", + }, + Response: systemnexusgen.ProtoType{ + PkgPath: "go.temporal.io/api/workflowservice/v1", + PkgName: "workflowservice", + Name: "SignalWithStartWorkflowExecutionResponse", + }, + }, + } + + out, err := systemnexusgen.GenerateCode("temporalcli", ops) + require.NoError(t, err) + src := string(out) + + require.True(t, strings.HasPrefix(src, "// Code generated by gen-system-nexus. DO NOT EDIT.")) + require.Contains(t, src, "package temporalcli") + require.Contains(t, src, "var systemNexusOps = map[systemNexusOpKey]systemNexusOpTypes{") + require.Contains(t, src, "Operation: workflowservicenexus.TemporalAPIWorkflowserviceV1WorkflowService.SignalWithStartWorkflowExecution.Name(),") + require.Contains(t, src, "func() proto.Message { return &workflowservice.SignalWithStartWorkflowExecutionRequest{} }") + require.Contains(t, src, "func() proto.Message { return &workflowservice.SignalWithStartWorkflowExecutionResponse{} }") + + // Output must be valid, gofmt-able Go. + _, err = parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors) + require.NoError(t, err) +} diff --git a/internal/systemnexusgen/parse.go b/internal/systemnexusgen/parse.go new file mode 100644 index 000000000..f714ec1d7 --- /dev/null +++ b/internal/systemnexusgen/parse.go @@ -0,0 +1,153 @@ +// Package systemnexusgen reads Temporal nexus service packages and generates +// the systemNexusOps map for the CLI. +package systemnexusgen + +import ( + "fmt" + "go/types" + "sort" + + "golang.org/x/tools/go/packages" +) + +// operationReferenceType is the fully qualified name of the generic nexus +// operation reference type whose type arguments are the request/response protos. +const operationReferenceType = "github.com/nexus-rpc/sdk-go/nexus.OperationReference" + +// ProtoType identifies a proto message type by its package and type name. +type ProtoType struct { + PkgPath string + PkgName string + Name string +} + +// Operation describes a single system Nexus operation found on a service struct. +type Operation struct { + NexusPkgPath string // import path of the package declaring the service struct + NexusPkgName string // package name of that package + ServiceVarName string // e.g. TemporalAPIWorkflowserviceV1WorkflowService + OpFieldName string // e.g. SignalWithStartWorkflowExecution + Request ProtoType + Response ProtoType +} + +// Parse loads the given import paths and returns all system Nexus operations +// found on their service structs, sorted deterministically. +func Parse(importPaths ...string) ([]Operation, error) { + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | + packages.NeedImports | packages.NeedDeps, + } + pkgs, err := packages.Load(cfg, importPaths...) + if err != nil { + return nil, fmt.Errorf("loading packages: %w", err) + } + + var ops []Operation + for _, pkg := range pkgs { + if len(pkg.Errors) > 0 { + return nil, fmt.Errorf("package %s has errors: %v", pkg.PkgPath, pkg.Errors) + } + ops = append(ops, operationsFromPackage(pkg)...) + } + + sort.Slice(ops, func(i, j int) bool { + if ops[i].NexusPkgPath != ops[j].NexusPkgPath { + return ops[i].NexusPkgPath < ops[j].NexusPkgPath + } + if ops[i].ServiceVarName != ops[j].ServiceVarName { + return ops[i].ServiceVarName < ops[j].ServiceVarName + } + return ops[i].OpFieldName < ops[j].OpFieldName + }) + return ops, nil +} + +func operationsFromPackage(pkg *packages.Package) []Operation { + var ops []Operation + scope := pkg.Types.Scope() + for _, name := range scope.Names() { + v, ok := scope.Lookup(name).(*types.Var) + if !ok { + continue + } + strct, ok := v.Type().Underlying().(*types.Struct) + if !ok || !isServiceStruct(strct) { + continue + } + for field := range strct.Fields() { + req, resp, ok := operationRefTypeArgs(field.Type()) + if !ok { + continue + } + ops = append(ops, Operation{ + NexusPkgPath: pkg.PkgPath, + NexusPkgName: pkg.Name, + ServiceVarName: v.Name(), + OpFieldName: field.Name(), + Request: req, + Response: resp, + }) + } + } + return ops +} + +// isServiceStruct reports whether strct looks like a nexus service struct: +// it has a `ServiceName string` field and at least one OperationReference field. +func isServiceStruct(strct *types.Struct) bool { + hasServiceName, hasOperation := false, false + for f := range strct.Fields() { + if f.Name() == "ServiceName" { + if b, ok := f.Type().(*types.Basic); ok && b.Kind() == types.String { + hasServiceName = true + } + } + if _, _, ok := operationRefTypeArgs(f.Type()); ok { + hasOperation = true + } + } + return hasServiceName && hasOperation +} + +// operationRefTypeArgs returns the request/response proto types when t is an +// instantiated nexus.OperationReference[Req, Resp]. +func operationRefTypeArgs(t types.Type) (ProtoType, ProtoType, bool) { + named, ok := t.(*types.Named) + if !ok { + return ProtoType{}, ProtoType{}, false + } + obj := named.Obj() + if obj.Pkg() == nil || obj.Pkg().Path()+"."+obj.Name() != operationReferenceType { + return ProtoType{}, ProtoType{}, false + } + args := named.TypeArgs() + if args == nil || args.Len() != 2 { + return ProtoType{}, ProtoType{}, false + } + req, ok := protoTypeFrom(args.At(0)) + if !ok { + return ProtoType{}, ProtoType{}, false + } + resp, ok := protoTypeFrom(args.At(1)) + if !ok { + return ProtoType{}, ProtoType{}, false + } + return req, resp, true +} + +func protoTypeFrom(t types.Type) (ProtoType, bool) { + named, ok := t.(*types.Named) + if !ok { + return ProtoType{}, false + } + obj := named.Obj() + if obj.Pkg() == nil { + return ProtoType{}, false + } + return ProtoType{ + PkgPath: obj.Pkg().Path(), + PkgName: obj.Pkg().Name(), + Name: obj.Name(), + }, true +} diff --git a/internal/systemnexusgen/parse_test.go b/internal/systemnexusgen/parse_test.go new file mode 100644 index 000000000..32403dae5 --- /dev/null +++ b/internal/systemnexusgen/parse_test.go @@ -0,0 +1,36 @@ +package systemnexusgen_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/temporalio/cli/internal/systemnexusgen" +) + +func TestParseDiscoversWorkflowService(t *testing.T) { + ops, err := systemnexusgen.Parse("go.temporal.io/api/workflowservice/v1/workflowservicenexus") + require.NoError(t, err) + + var got *systemnexusgen.Operation + for i := range ops { + if ops[i].OpFieldName == "SignalWithStartWorkflowExecution" { + got = &ops[i] + break + } + } + require.NotNil(t, got, "expected SignalWithStartWorkflowExecution operation") + + require.Equal(t, "go.temporal.io/api/workflowservice/v1/workflowservicenexus", got.NexusPkgPath) + require.Equal(t, "workflowservicenexus", got.NexusPkgName) + require.Equal(t, "TemporalAPIWorkflowserviceV1WorkflowService", got.ServiceVarName) + require.Equal(t, systemnexusgen.ProtoType{ + PkgPath: "go.temporal.io/api/workflowservice/v1", + PkgName: "workflowservice", + Name: "SignalWithStartWorkflowExecutionRequest", + }, got.Request) + require.Equal(t, systemnexusgen.ProtoType{ + PkgPath: "go.temporal.io/api/workflowservice/v1", + PkgName: "workflowservice", + Name: "SignalWithStartWorkflowExecutionResponse", + }, got.Response) +} diff --git a/internal/temporalcli/commands.system_nexus.gen.go b/internal/temporalcli/commands.system_nexus.gen.go new file mode 100644 index 000000000..7d99e2c43 --- /dev/null +++ b/internal/temporalcli/commands.system_nexus.gen.go @@ -0,0 +1,22 @@ +// Code generated by gen-system-nexus. DO NOT EDIT. + +package temporalcli + +import ( + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/api/workflowservice/v1/workflowservicenexus" + "google.golang.org/protobuf/proto" +) + +// systemNexusOps is the generated registry of known system Nexus operations on +// the __temporal_system endpoint. Regenerate with 'make gen' after bumping the +// go.temporal.io/api dependency. +var systemNexusOps = map[systemNexusOpKey]systemNexusOpTypes{ + { + Endpoint: temporalSystemNexusEndpoint, + Operation: workflowservicenexus.TemporalAPIWorkflowserviceV1WorkflowService.SignalWithStartWorkflowExecution.Name(), + }: { + NewRequest: func() proto.Message { return &workflowservice.SignalWithStartWorkflowExecutionRequest{} }, + NewResponse: func() proto.Message { return &workflowservice.SignalWithStartWorkflowExecutionResponse{} }, + }, +} diff --git a/internal/temporalcli/commands.system_nexus.go b/internal/temporalcli/commands.system_nexus.go index da352b3c0..b5e8568c9 100644 --- a/internal/temporalcli/commands.system_nexus.go +++ b/internal/temporalcli/commands.system_nexus.go @@ -5,8 +5,6 @@ import ( commonpb "go.temporal.io/api/common/v1" "go.temporal.io/api/proxy" - "go.temporal.io/api/workflowservice/v1" - "go.temporal.io/api/workflowservice/v1/workflowservicenexus" "go.temporal.io/sdk/converter" "google.golang.org/protobuf/proto" ) @@ -26,22 +24,6 @@ type systemNexusOpTypes struct { NewResponse func() proto.Message } -// systemNexusOps is the global registry of known system Nexus operations on the -// __temporal_system endpoint. Add new entries here as the server adds support for more -// system operations. The keys' Operation values must match what the server records in -// NexusOperationScheduledEventAttributes.Operation. -// NOTE seankane: Part 2 of the System Operations work is to code generate this map from the -// go.temporal.io/api/workflowservice/v1/workflowservicenexus package. -var systemNexusOps = map[systemNexusOpKey]systemNexusOpTypes{ - { - Endpoint: temporalSystemNexusEndpoint, - Operation: workflowservicenexus.TemporalAPIWorkflowserviceV1WorkflowService.SignalWithStartWorkflowExecution.Name(), - }: { - NewRequest: func() proto.Message { return &workflowservice.SignalWithStartWorkflowExecutionRequest{} }, - NewResponse: func() proto.Message { return &workflowservice.SignalWithStartWorkflowExecutionResponse{} }, - }, -} - // decodePayloadsInProto walks a proto message and applies codec.Decode to every Payload // found inside it (including nested messages). The message is mutated in place. func decodePayloadsInProto(ctx context.Context, msg proto.Message, codec converter.PayloadCodec) error { diff --git a/internal/temporalcli/commands.system_nexus_gen_test.go b/internal/temporalcli/commands.system_nexus_gen_test.go new file mode 100644 index 000000000..e8eafc780 --- /dev/null +++ b/internal/temporalcli/commands.system_nexus_gen_test.go @@ -0,0 +1,26 @@ +package temporalcli_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/temporalio/cli/internal/systemnexusgen" +) + +// TestSystemNexusGenUpToDate fails if commands.system_nexus.gen.go is stale, +// e.g. after a go.temporal.io/api bump adds or changes a system Nexus operation. +// Run 'make gen' to regenerate. +func TestSystemNexusGenUpToDate(t *testing.T) { + ops, err := systemnexusgen.Parse("go.temporal.io/api/workflowservice/v1/workflowservicenexus") + require.NoError(t, err) + + want, err := systemnexusgen.GenerateCode("temporalcli", ops) + require.NoError(t, err) + + got, err := os.ReadFile("commands.system_nexus.gen.go") + require.NoError(t, err) + + require.Equal(t, string(want), string(got), + "commands.system_nexus.gen.go is out of date; run 'make gen'") +}