Skip to content

Commit 7b883ee

Browse files
authored
feat: implement release-init container contract (#1677)
Fixes #1011
1 parent 4b35724 commit 7b883ee

File tree

10 files changed

+530
-160
lines changed

10 files changed

+530
-160
lines changed

internal/config/config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const (
4545
// LibrarianDir is the default directory to store librarian state/config files,
4646
// along with any additional configuration.
4747
LibrarianDir = ".librarian"
48+
// ReleaseInitRequest is a JSON file that describes which library to release.
49+
ReleaseInitRequest = "release-init-request.json"
4850
)
4951

5052
// Config holds all configuration values parsed from flags or environment
@@ -118,6 +120,16 @@ type Config struct {
118120
// api is specified all currently managed libraries will be regenerated.
119121
Library string
120122

123+
// LibraryVersion is the library version to release.
124+
//
125+
// Overrides the automatic semantic version calculation and forces a specific
126+
// version for a library.
127+
// This is intended for exceptional cases, such as applying a backport patch
128+
// or forcing a major version bump.
129+
//
130+
// Requires the --library flag to be specified.
131+
LibraryVersion string
132+
121133
// Push determines whether to push changes to GitHub. It is used in
122134
// all commands that create commits in a language repository:
123135
// configure and update-apis.
@@ -206,6 +218,10 @@ func (c *Config) IsValid() (bool, error) {
206218
return false, errors.New("no GitHub token supplied for push")
207219
}
208220

221+
if c.Library == "" && c.LibraryVersion != "" {
222+
return false, errors.New("specified library version without library id")
223+
}
224+
209225
if _, err := validateHostMount(c.HostMount, ""); err != nil {
210226
return false, err
211227
}

internal/config/config_test.go

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package config
1717
import (
1818
"errors"
1919
"os/user"
20+
"strings"
2021
"testing"
2122

2223
"github.com/google/go-cmp/cmp"
@@ -128,81 +129,88 @@ func TestSetupUser(t *testing.T) {
128129

129130
func TestIsValid(t *testing.T) {
130131
for _, test := range []struct {
131-
name string
132-
cfg Config
133-
wantValid bool
134-
wantErr bool
132+
name string
133+
cfg Config
134+
wantErr bool
135+
wantErrMsg string
135136
}{
136137
{
137138
name: "Valid config - Push false",
138139
cfg: Config{
139140
Push: false,
140141
GitHubToken: "",
141142
},
142-
wantValid: true,
143-
wantErr: false,
144143
},
145144
{
146145
name: "Valid config - Push true, token present",
147146
cfg: Config{
148147
Push: true,
149148
GitHubToken: "some_token",
150149
},
151-
wantValid: true,
152-
wantErr: false,
150+
},
151+
{
152+
name: "Valid config - missing library version",
153+
cfg: Config{
154+
Push: true,
155+
GitHubToken: "some_token",
156+
Library: "library-id",
157+
},
153158
},
154159
{
155160
name: "Invalid config - Push true, token missing",
156161
cfg: Config{
157162
Push: true,
158163
GitHubToken: "",
159164
},
160-
wantValid: false,
161-
wantErr: true,
165+
wantErr: true,
166+
wantErrMsg: "no GitHub token supplied for push",
167+
},
168+
{
169+
name: "Invalid config - library version presents, missing library id",
170+
cfg: Config{
171+
Push: true,
172+
GitHubToken: "some_token",
173+
LibraryVersion: "1.2.3",
174+
},
175+
wantErr: true,
176+
wantErrMsg: "specified library version without library id",
162177
},
163178
{
164179
name: "Invalid config - host mount invalid, missing local-dir",
165180
cfg: Config{
166181
Push: false,
167182
HostMount: "host-dir:",
168183
},
169-
wantValid: false,
170-
wantErr: true,
184+
wantErr: true,
185+
wantErrMsg: "unable to parse host mount",
171186
},
172187
{
173188
name: "Invalid config - host mount invalid, missing host-dir",
174189
cfg: Config{
175190
Push: false,
176191
HostMount: ":local-dir",
177192
},
178-
wantValid: false,
179-
wantErr: true,
193+
wantErr: true,
194+
wantErrMsg: "unable to parse host mount",
180195
},
181196
{
182197
name: "Invalid config - host mount invalid, missing separator",
183198
cfg: Config{
184199
Push: false,
185200
HostMount: "host-dir/local-dir",
186201
},
187-
wantValid: false,
188-
wantErr: true,
202+
wantErr: true,
203+
wantErrMsg: "unable to parse host mount",
189204
},
190205
} {
191206
t.Run(test.name, func(t *testing.T) {
192207
gotValid, err := test.cfg.IsValid()
193208

194-
if gotValid != test.wantValid {
195-
t.Errorf("IsValid() got valid = %t, want %t", gotValid, test.wantValid)
209+
if gotValid != !test.wantErr {
210+
t.Errorf("IsValid() got valid = %t, want %t", gotValid, !test.wantErr)
196211
}
197212

198-
if (err != nil) != test.wantErr {
199-
t.Errorf("IsValid() got error = %v, want error = %t", err, test.wantErr)
200-
}
201-
if test.wantErr &&
202-
err != nil &&
203-
err.Error() != "no GitHub token supplied for push" &&
204-
err.Error() != "unable to parse push config" &&
205-
err.Error() != "unable to parse host mount" {
213+
if test.wantErr && !strings.Contains(err.Error(), test.wantErrMsg) {
206214
t.Errorf("IsValid() got unexpected error message: %q", err.Error())
207215
}
208216
})

internal/config/state.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import (
2424
// LibrarianState defines the contract for the state.yaml file.
2525
type LibrarianState struct {
2626
// The name and tag of the generator image to use. tag is required.
27-
Image string `yaml:"image"`
27+
Image string `yaml:"image" json:"image"`
2828
// A list of library configurations.
29-
Libraries []*LibraryState `yaml:"libraries"`
29+
Libraries []*LibraryState `yaml:"libraries" json:"libraries"`
3030
}
3131

3232
// Validate checks that the LibrarianState is valid.

internal/docker/docker.go

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ type Command string
3737

3838
// The set of commands passed to the language container, in a single place to avoid typos.
3939
const (
40-
// CommandGenerate performs generation for a configured library.
41-
CommandGenerate Command = "generate"
4240
// CommandBuild builds a library.
4341
CommandBuild Command = "build"
4442
// CommandConfigure configures a new API as a library.
4543
CommandConfigure Command = "configure"
44+
// CommandGenerate performs generation for a configured library.
45+
CommandGenerate Command = "generate"
46+
// CommandReleaseInit performs release for a library.
47+
CommandReleaseInit Command = "release-init"
4648
)
4749

4850
// Docker contains all the information required to run language-specific
@@ -61,44 +63,41 @@ type Docker struct {
6163
run func(args ...string) error
6264
}
6365

64-
// GenerateRequest contains all the information required for a language
65-
// container to run the generate command.
66-
type GenerateRequest struct {
66+
// BuildRequest contains all the information required for a language
67+
// container to run the build command.
68+
type BuildRequest struct {
6769
// cfg is a pointer to the [config.Config] struct, holding general configuration
6870
// values parsed from flags or environment variables.
6971
Cfg *config.Config
7072
// state is a pointer to the [config.LibrarianState] struct, representing
7173
// the overall state of the generation and release pipeline.
7274
State *config.LibrarianState
73-
// apiRoot specifies the root directory of the API specification repo.
74-
ApiRoot string
75-
// libraryID specifies the ID of the library to generate
75+
// libraryID specifies the ID of the library to build.
7676
LibraryID string
77-
// output specifies the empty output directory into which the command should
78-
// generate code
79-
Output string
8077
// RepoDir is the local root directory of the language repository.
8178
RepoDir string
8279
}
8380

84-
// BuildRequest contains all the information required for a language
85-
// container to run the build command.
86-
type BuildRequest struct {
81+
// ConfigureRequest contains all the information required for a language
82+
// container to run the configure command.
83+
type ConfigureRequest struct {
8784
// cfg is a pointer to the [config.Config] struct, holding general configuration
8885
// values parsed from flags or environment variables.
8986
Cfg *config.Config
9087
// state is a pointer to the [config.LibrarianState] struct, representing
9188
// the overall state of the generation and release pipeline.
9289
State *config.LibrarianState
93-
// libraryID specifies the ID of the library to build
90+
// apiRoot specifies the root directory of the API specification repo.
91+
ApiRoot string
92+
// libraryID specifies the ID of the library to configure.
9493
LibraryID string
9594
// RepoDir is the local root directory of the language repository.
9695
RepoDir string
9796
}
9897

99-
// ConfigureRequest contains all the information required for a language
100-
// container to run the configure command.
101-
type ConfigureRequest struct {
98+
// GenerateRequest contains all the information required for a language
99+
// container to run the generate command.
100+
type GenerateRequest struct {
102101
// cfg is a pointer to the [config.Config] struct, holding general configuration
103102
// values parsed from flags or environment variables.
104103
Cfg *config.Config
@@ -107,8 +106,31 @@ type ConfigureRequest struct {
107106
State *config.LibrarianState
108107
// apiRoot specifies the root directory of the API specification repo.
109108
ApiRoot string
110-
// libraryID specifies the ID of the library to generate
109+
// libraryID specifies the ID of the library to generate.
111110
LibraryID string
111+
// output specifies the empty output directory into which the command should
112+
// generate code
113+
Output string
114+
// RepoDir is the local root directory of the language repository.
115+
RepoDir string
116+
}
117+
118+
// ReleaseRequest contains all the information required for a language
119+
// container to run the release command.
120+
type ReleaseRequest struct {
121+
// cfg is a pointer to the [config.Config] struct, holding general configuration
122+
// values parsed from flags or environment variables.
123+
Cfg *config.Config
124+
// state is a pointer to the [config.LibrarianState] struct, representing
125+
// the overall state of the generation and release pipeline.
126+
State *config.LibrarianState
127+
// libraryID specifies the ID of the library to release.
128+
LibraryID string
129+
// libraryID specifies the version of the library to release.
130+
LibraryVersion string
131+
// output specifies the empty output directory into which the command should
132+
// generate code
133+
Output string
112134
// RepoDir is the local root directory of the language repository.
113135
RepoDir string
114136
}
@@ -132,7 +154,7 @@ func New(workRoot, image, uid, gid string) (*Docker, error) {
132154
// library.
133155
func (c *Docker) Generate(ctx context.Context, request *GenerateRequest) error {
134156
jsonFilePath := filepath.Join(request.RepoDir, config.LibrarianDir, config.GenerateRequest)
135-
if err := writeRequest(request.State, request.LibraryID, jsonFilePath); err != nil {
157+
if err := writeLibraryState(request.State, request.LibraryID, jsonFilePath); err != nil {
136158
return err
137159
}
138160
defer func(name string) {
@@ -165,7 +187,7 @@ func (c *Docker) Generate(ctx context.Context, request *GenerateRequest) error {
165187
// the Librarian state file for the repository with a root of repoRoot.
166188
func (c *Docker) Build(ctx context.Context, request *BuildRequest) error {
167189
jsonFilePath := filepath.Join(request.RepoDir, config.LibrarianDir, config.BuildRequest)
168-
if err := writeRequest(request.State, request.LibraryID, jsonFilePath); err != nil {
190+
if err := writeLibraryState(request.State, request.LibraryID, jsonFilePath); err != nil {
169191
return err
170192
}
171193
defer func(name string) {
@@ -194,7 +216,7 @@ func (c *Docker) Build(ctx context.Context, request *BuildRequest) error {
194216
// Returns the configured library id if the command succeeds.
195217
func (c *Docker) Configure(ctx context.Context, request *ConfigureRequest) (string, error) {
196218
requestFilePath := filepath.Join(request.RepoDir, config.LibrarianDir, config.ConfigureRequest)
197-
if err := writeRequest(request.State, request.LibraryID, requestFilePath); err != nil {
219+
if err := writeLibraryState(request.State, request.LibraryID, requestFilePath); err != nil {
198220
return "", err
199221
}
200222
defer func() {
@@ -223,6 +245,44 @@ func (c *Docker) Configure(ctx context.Context, request *ConfigureRequest) (stri
223245
return request.LibraryID, nil
224246
}
225247

248+
// ReleaseInit initiates a release for a given language repository.
249+
func (c *Docker) ReleaseInit(ctx context.Context, request *ReleaseRequest) error {
250+
requestFilePath := filepath.Join(request.RepoDir, config.LibrarianDir, config.ReleaseInitRequest)
251+
if err := writeLibrarianState(request.State, requestFilePath); err != nil {
252+
return err
253+
}
254+
defer func() {
255+
err := os.Remove(requestFilePath)
256+
if err != nil {
257+
slog.Warn("fail to remove file", slog.String("name", requestFilePath), slog.Any("err", err))
258+
}
259+
}()
260+
commandArgs := []string{
261+
"--librarian=/librarian",
262+
"--repo=/repo",
263+
"--output=/output",
264+
}
265+
if request.LibraryID != "" {
266+
commandArgs = append(commandArgs, fmt.Sprintf("--library=%s", request.LibraryID))
267+
}
268+
if request.LibraryVersion != "" {
269+
commandArgs = append(commandArgs, fmt.Sprintf("--library-version=%s", request.LibraryVersion))
270+
}
271+
272+
librarianDir := filepath.Join(request.RepoDir, config.LibrarianDir)
273+
mounts := []string{
274+
fmt.Sprintf("%s:/librarian", librarianDir),
275+
fmt.Sprintf("%s:/repo:ro", request.RepoDir), // readonly volume
276+
fmt.Sprintf("%s:/output", request.Output),
277+
}
278+
279+
if err := c.runDocker(ctx, request.Cfg, CommandReleaseInit, mounts, commandArgs); err != nil {
280+
return err
281+
}
282+
283+
return nil
284+
}
285+
226286
func (c *Docker) runDocker(_ context.Context, cfg *config.Config, command Command, mounts []string, commandArgs []string) (err error) {
227287
mounts = maybeRelocateMounts(cfg, mounts)
228288

@@ -277,13 +337,13 @@ func (c *Docker) runCommand(cmdName string, args ...string) error {
277337
return err
278338
}
279339

280-
func writeRequest(state *config.LibrarianState, libraryID, jsonFilePath string) error {
340+
func writeLibraryState(state *config.LibrarianState, libraryID, jsonFilePath string) error {
281341
if err := os.MkdirAll(filepath.Dir(jsonFilePath), 0755); err != nil {
282342
return fmt.Errorf("failed to make directory: %w", err)
283343
}
284344
jsonFile, err := os.Create(jsonFilePath)
285345
if err != nil {
286-
return fmt.Errorf("failed to create generate request JSON file: %w", err)
346+
return fmt.Errorf("failed to create JSON file: %w", err)
287347
}
288348
defer jsonFile.Close()
289349

@@ -304,3 +364,23 @@ func writeRequest(state *config.LibrarianState, libraryID, jsonFilePath string)
304364

305365
return nil
306366
}
367+
368+
func writeLibrarianState(state *config.LibrarianState, jsonFilePath string) error {
369+
if err := os.MkdirAll(filepath.Dir(jsonFilePath), 0755); err != nil {
370+
return fmt.Errorf("failed to make directory: %w", err)
371+
}
372+
jsonFile, err := os.Create(jsonFilePath)
373+
if err != nil {
374+
return fmt.Errorf("failed to create JSON file: %w", err)
375+
}
376+
defer jsonFile.Close()
377+
378+
data, err := json.MarshalIndent(state, "", " ")
379+
if err != nil {
380+
return fmt.Errorf("failed to marshal state to JSON: %w", err)
381+
}
382+
383+
_, err = jsonFile.Write(data)
384+
385+
return err
386+
}

0 commit comments

Comments
 (0)