Skip to content

Commit be676c9

Browse files
authored
tools: first pass at a state file generator for google-cloud-go (#12776)
1 parent bfcae15 commit be676c9

File tree

6 files changed

+339
-0
lines changed

6 files changed

+339
-0
lines changed

go.work

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ use (
102102
./internal/librariangen
103103
./internal/postprocessor
104104
./internal/protoveneer
105+
./internal/stategen
105106
./iot
106107
./kms
107108
./language

internal/stategen/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Librarian state file generator
2+
3+
This is a state file generator for Librarian, for google-cloud-go.
4+
5+
Command line arguments: repo-root module1 module2...
6+
7+
- repo-root: Path to the repository root
8+
- module1, module2, ...: Modules to add to the state file
9+
10+
Example from the root directory:
11+
12+
```sh
13+
$ go run ./internal/stategen . spanner apphub
14+
```
15+
16+
It is expected that the state file (`.librarian/state.yaml`) already
17+
exists. Any libraries that already exist within the state file are
18+
ignored, even if they're listed in the command line.

internal/stategen/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module cloud.google.com/go/internal/stategen
2+
3+
go 1.23.0
4+
5+
require gopkg.in/yaml.v3 v3.0.1

internal/stategen/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
2+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
4+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/stategen/main.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"log/slog"
21+
"os"
22+
"path/filepath"
23+
"strings"
24+
)
25+
26+
// main is the entrypoint for the stategen tool, which updates a Librarian state.yaml
27+
// file to include the specified modules. The first argument is a path to the repository
28+
// root; all subsequent arguments are module names.
29+
func main() {
30+
logLevel := slog.LevelInfo
31+
if os.Getenv("GOOGLE_SDK_GO_LOGGING_LEVEL") == "debug" {
32+
logLevel = slog.LevelDebug
33+
}
34+
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
35+
Level: logLevel,
36+
})))
37+
slog.Info("stategen: invoked", "args", os.Args)
38+
if err := run(os.Args[1:]); err != nil {
39+
slog.Error("stategen: failed", "error", err)
40+
os.Exit(1)
41+
}
42+
slog.Info("stategen: finished successfully")
43+
}
44+
45+
func run(args []string) error {
46+
if len(args) < 2 {
47+
return errors.New("stategen: expected a root directory and at least one module")
48+
}
49+
repoRoot := args[0]
50+
stateFilePath := filepath.Join(repoRoot, ".librarian/state.yaml")
51+
state, err := parseLibrarianState(stateFilePath)
52+
if err != nil {
53+
return err
54+
}
55+
for _, moduleName := range args[1:] {
56+
if stateContainsModule(state, moduleName) {
57+
slog.Info("skipping existing module", "module", moduleName)
58+
continue
59+
}
60+
if err := addModule(repoRoot, state, moduleName); err != nil {
61+
return err
62+
}
63+
}
64+
return saveLibrarianState(stateFilePath, state)
65+
}
66+
67+
func stateContainsModule(state *LibrarianState, moduleName string) bool {
68+
for _, library := range state.Libraries {
69+
if library.ID == moduleName {
70+
return true
71+
}
72+
}
73+
return false
74+
}
75+
76+
func addModule(repoRoot string, state *LibrarianState, moduleName string) error {
77+
slog.Info("adding module", "module", moduleName)
78+
moduleRoot := filepath.Join(repoRoot, moduleName)
79+
80+
// Start off with the basics which need
81+
library := &LibraryState{
82+
ID: moduleName,
83+
SourceRoots: []string{
84+
moduleName,
85+
"internal/generated/snippets/" + moduleName,
86+
},
87+
RemoveRegex: []string{
88+
moduleName + "/README\\.md",
89+
moduleName + "/go\\.mod",
90+
moduleName + "/go\\.sum",
91+
moduleName + "/internal/version\\.go",
92+
"internal/generated/snippets/" + moduleName,
93+
},
94+
}
95+
96+
version, err := loadVersion(moduleRoot)
97+
if err != nil {
98+
return err
99+
}
100+
library.Version = version
101+
102+
if err := addAPIProtoPaths(repoRoot, moduleName, library); err != nil {
103+
return err
104+
}
105+
106+
if err := addGeneratedCodeRemovals(repoRoot, moduleRoot, library); err != nil {
107+
return err
108+
}
109+
110+
state.Libraries = append(state.Libraries, library)
111+
return nil
112+
}
113+
114+
// addAPIProtoPaths walks the generates snippets directory to find the API proto paths for the library.
115+
func addAPIProtoPaths(repoRoot, moduleName string, library *LibraryState) error {
116+
return filepath.WalkDir(filepath.Join(repoRoot, "internal/generated/snippets/"+moduleName), func(path string, d os.DirEntry, err error) error {
117+
if err != nil {
118+
return err
119+
}
120+
if d.IsDir() {
121+
return nil
122+
}
123+
if match, _ := filepath.Match("snippet_metadata.*.json", d.Name()); match {
124+
parts := strings.Split(d.Name(), ".")
125+
parts = parts[1 : len(parts)-1]
126+
api := &API{
127+
Path: strings.Join(parts, "/"),
128+
}
129+
library.APIs = append(library.APIs, api)
130+
}
131+
return nil
132+
})
133+
}
134+
135+
// addApiPaths walk the module source directory to find the files to remove.
136+
func addGeneratedCodeRemovals(repoRoot, moduleRoot string, library *LibraryState) error {
137+
return filepath.WalkDir(moduleRoot, func(path string, d os.DirEntry, err error) error {
138+
if err != nil {
139+
return err
140+
}
141+
if !d.IsDir() {
142+
return nil
143+
}
144+
if !strings.HasPrefix(d.Name(), "apiv") {
145+
return nil
146+
}
147+
repoRelativePath, err := filepath.Rel(repoRoot, path)
148+
if err != nil {
149+
return err
150+
}
151+
apiParts := strings.Split(path, "/")
152+
protobufDir := apiParts[len(apiParts)-2] + "pb"
153+
generatedPaths := []string{
154+
"[^/]*_client\\.go",
155+
"[^/]*_client_example_go123_test\\.go",
156+
"[^/]*_client_example_test\\.go",
157+
"auxiliary\\.go",
158+
"auxiliary_go123\\.go",
159+
"doc\\.go",
160+
"gapic_metadata\\.json",
161+
"helpers\\.go",
162+
"version\\.go",
163+
protobufDir,
164+
}
165+
for _, generatedPath := range generatedPaths {
166+
library.RemoveRegex = append(library.RemoveRegex, repoRelativePath+"/"+generatedPath)
167+
}
168+
return nil
169+
})
170+
}
171+
172+
func loadVersion(moduleRoot string) (string, error) {
173+
// Load internal/version.go to figure out the existing version.
174+
versionPath := filepath.Join(moduleRoot, "internal/version.go")
175+
versionBytes, err := os.ReadFile(versionPath)
176+
if err != nil {
177+
return "", err
178+
}
179+
lines := strings.Split(string(versionBytes), "\n")
180+
lastLine := lines[len(lines)-1]
181+
// If the actual last line is empty, use the previous one instead.
182+
if lastLine == "" {
183+
lastLine = lines[len(lines)-2]
184+
}
185+
if !strings.HasPrefix(lastLine, "const Version") {
186+
return "", fmt.Errorf("stategen: version file not in expected format for module: %s; %s", versionPath, lastLine)
187+
}
188+
189+
versionParts := strings.Split(lastLine, "\"")
190+
if len(versionParts) != 3 {
191+
return "", fmt.Errorf("stategen: last line of version file not in expected format for module: %s", versionPath)
192+
}
193+
return versionParts[1], nil
194+
}

internal/stategen/state.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"fmt"
19+
"os"
20+
21+
"gopkg.in/yaml.v3"
22+
)
23+
24+
// Copied from https://github.com/googleapis/librarian/blob/main/internal/config/state.go
25+
26+
// LibrarianState defines the contract for the state.yaml file.
27+
type LibrarianState struct {
28+
// The name and tag of the generator image to use. tag is required.
29+
Image string `yaml:"image" json:"image"`
30+
// A list of library configurations.
31+
Libraries []*LibraryState `yaml:"libraries" json:"libraries"`
32+
}
33+
34+
// LibraryState represents the state of a single library within state.yaml.
35+
type LibraryState struct {
36+
// A unique identifier for the library, in a language-specific format.
37+
// A valid ID should not be empty and only contains alphanumeric characters, slashes, periods, underscores, and hyphens.
38+
ID string `yaml:"id" json:"id"`
39+
// The last released version of the library, following SemVer.
40+
Version string `yaml:"version" json:"version"`
41+
// The commit hash from the API definition repository at which the library was last generated.
42+
LastGeneratedCommit string `yaml:"last_generated_commit" json:"last_generated_commit"`
43+
// The changes from the language repository since the library was last released.
44+
// This field is ignored when writing to state.yaml.
45+
Changes []*Change `yaml:"-" json:"changes,omitempty"`
46+
// A list of APIs that are part of this library.
47+
APIs []*API `yaml:"apis" json:"apis"`
48+
// A list of directories in the language repository where Librarian contributes code.
49+
SourceRoots []string `yaml:"source_roots" json:"source_roots"`
50+
// A list of regular expressions for files and directories to preserve during the copy and remove process.
51+
PreserveRegex []string `yaml:"preserve_regex" json:"preserve_regex"`
52+
// A list of regular expressions for files and directories to remove before copying generated code.
53+
// If not set, this defaults to the `source_roots`.
54+
// A more specific `preserve_regex` takes precedence.
55+
RemoveRegex []string `yaml:"remove_regex" json:"remove_regex"`
56+
// Path of commits to be excluded from parsing while calculating library changes.
57+
// If all files from commit belong to one of the paths it will be skipped.
58+
ReleaseExcludePaths []string `yaml:"release_exclude_paths,omitempty" json:"release_exclude_paths,omitempty"`
59+
// Specifying a tag format allows librarian to honor this format when creating
60+
// a tag for the release of the library. The replacement values of {id} and {version}
61+
// permitted to reference the values configured in the library. If not specified
62+
// the assumed format is {id}-{version}. e.g., {id}/v{version}.
63+
TagFormat string `yaml:"tag_format,omitempty" json:"tag_format,omitempty"`
64+
// Whether including this library in a release.
65+
// This field is ignored when writing to state.yaml.
66+
ReleaseTriggered bool `yaml:"-" json:"release_triggered,omitempty"`
67+
// An error message from the docker response.
68+
// This field is ignored when writing to state.yaml.
69+
ErrorMessage string `yaml:"-" json:"error,omitempty"`
70+
}
71+
72+
// API represents an API that is part of a library.
73+
type API struct {
74+
// The path to the API, relative to the root of the API definition repository (e.g., "google/storage/v1").
75+
Path string `yaml:"path" json:"path"`
76+
// The name of the service config file, relative to the API `path`.
77+
ServiceConfig string `yaml:"service_config" json:"service_config"`
78+
// The status of the API, one of "new" or "existing".
79+
// This field is ignored when writing to state.yaml.
80+
Status string `yaml:"-" json:"status"`
81+
}
82+
83+
// Change represents the changelog of a library.
84+
type Change struct {
85+
// The type of the change, should be one of the conventional type.
86+
Type string `yaml:"type" json:"type"`
87+
// The summary of the change.
88+
Subject string `yaml:"subject" json:"subject"`
89+
// The body of the change.
90+
Body string `yaml:"body" json:"body"`
91+
// The Changelist number in piper associated with this change.
92+
ClNum string `yaml:"piper_cl_number" json:"piper_cl_number"`
93+
// The commit hash in the source repository associated with this change.
94+
CommitHash string `yaml:"source_commit_hash" json:"source_commit_hash"`
95+
}
96+
97+
// Copied from https://github.com/googleapis/librarian/blob/main/internal/librarian/state.go
98+
// with minimal modification
99+
func saveLibrarianState(path string, state *LibrarianState) error {
100+
bytes, err := yaml.Marshal(state)
101+
if err != nil {
102+
return err
103+
}
104+
return os.WriteFile(path, bytes, 0644)
105+
}
106+
107+
func parseLibrarianState(path string) (*LibrarianState, error) {
108+
bytes, err := os.ReadFile(path)
109+
if err != nil {
110+
return nil, err
111+
}
112+
var s LibrarianState
113+
if err := yaml.Unmarshal(bytes, &s); err != nil {
114+
return nil, fmt.Errorf("unmarshaling librarian state: %w", err)
115+
}
116+
return &s, nil
117+
}

0 commit comments

Comments
 (0)