Skip to content

Commit da41e6c

Browse files
authored
Enable dynamic changesetTemplates by adding templating & steps outputs (#424)
* Add templating to changesetTemplate and steps.outputs * Fix parsing of outputs * Remove leftovers in campaign spec schema * Remove debug output * Rename test function * Add a test for renderChangesetTemplateField * Update docs/examples in campaign spec schema * Get dynamic changeset templates working with cache * Simplify ExecutionCache by using ExecutionResult * Change interface of runSteps to return ExecutionResult * Add 'steps' to changesetTemplate template variables * Support templating in changesetTemplate.author fields * Fix doc comment * Only use yaml.v3 in internal/campaigns * Add tests for ExecutionCacheTest * Add proper backwards-compatibility to ExecutionDiskCache * Remove unneeded code * Add changelog entry * Add comment about lossiness of cache conversion
1 parent 48916da commit da41e6c

File tree

12 files changed

+998
-188
lines changed

12 files changed

+998
-188
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ All notable changes to `src-cli` are documented in this file.
1313

1414
### Added
1515

16+
- `steps` in campaign specs can now have [`outputs`](https://docs.sourcegraph.com/campaigns/references/campaign_spec_yaml_reference#steps-outputs) that support [templating](https://docs.sourcegraph.com/campaigns/references/campaign_spec_templating). [#424](https://github.com/sourcegraph/src-cli/pull/424)
17+
- `changesetTemplate` fields in campaign specs now also support [templating](https://docs.sourcegraph.com/campaigns/references/campaign_spec_templating). [#424](https://github.com/sourcegraph/src-cli/pull/424)
18+
1619
### Changed
1720

1821
### Fixed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
3131
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4
3232
gopkg.in/yaml.v2 v2.3.0
33+
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
3334
jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7
3435
)
3536

internal/campaigns/campaign_spec.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,18 @@ type Step struct {
7171
Container string `json:"container,omitempty" yaml:"container"`
7272
Env env.Environment `json:"env,omitempty" yaml:"env"`
7373
Files map[string]string `json:"files,omitempty" yaml:"files,omitempty"`
74+
Outputs Outputs `json:"outputs,omitempty" yaml:"outputs,omitempty"`
7475

7576
image string
7677
}
7778

79+
type Outputs map[string]Output
80+
81+
type Output struct {
82+
Value string `json:"value,omitempty" yaml:"value,omitempty"`
83+
Format string `json:"format,omitempty" yaml:"format,omitempty"`
84+
}
85+
7886
type TransformChanges struct {
7987
Group []Group `json:"group,omitempty" yaml:"group"`
8088
}

internal/campaigns/execution_cache.go

Lines changed: 139 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io/ioutil"
1010
"os"
1111
"path/filepath"
12+
"sort"
1213
"strings"
1314

1415
"github.com/pkg/errors"
@@ -62,73 +63,183 @@ func (key ExecutionCacheKey) Key() (string, error) {
6263
}
6364

6465
type ExecutionCache interface {
65-
Get(ctx context.Context, key ExecutionCacheKey) (diff string, found bool, err error)
66-
Set(ctx context.Context, key ExecutionCacheKey, diff string) error
66+
Get(ctx context.Context, key ExecutionCacheKey) (result ExecutionResult, found bool, err error)
67+
Set(ctx context.Context, key ExecutionCacheKey, result ExecutionResult) error
6768
Clear(ctx context.Context, key ExecutionCacheKey) error
6869
}
6970

7071
type ExecutionDiskCache struct {
7172
Dir string
7273
}
7374

75+
const cacheFileExt = ".v3.json"
76+
7477
func (c ExecutionDiskCache) cacheFilePath(key ExecutionCacheKey) (string, error) {
7578
keyString, err := key.Key()
7679
if err != nil {
7780
return "", errors.Wrap(err, "calculating execution cache key")
7881
}
7982

80-
return filepath.Join(c.Dir, keyString+".diff"), nil
83+
return filepath.Join(c.Dir, keyString+cacheFileExt), nil
8184
}
8285

83-
func (c ExecutionDiskCache) Get(ctx context.Context, key ExecutionCacheKey) (string, bool, error) {
86+
func (c ExecutionDiskCache) Get(ctx context.Context, key ExecutionCacheKey) (ExecutionResult, bool, error) {
87+
var result ExecutionResult
88+
8489
path, err := c.cacheFilePath(key)
8590
if err != nil {
86-
return "", false, err
91+
return result, false, err
8792
}
8893

89-
data, err := ioutil.ReadFile(path)
94+
// We try to be backwards compatible and see if we also find older cache
95+
// files.
96+
//
97+
// There are three different cache versions out in the wild and to be
98+
// backwards compatible we read all of them.
99+
//
100+
// In Sourcegraph/src-cli 3.26 we can remove the code here and simply read
101+
// the cache from `path`, since all the old cache files should be deleted
102+
// until then.
103+
globPattern := strings.TrimSuffix(path, cacheFileExt) + ".*"
104+
matches, err := filepath.Glob(globPattern)
90105
if err != nil {
91-
if os.IsNotExist(err) {
92-
err = nil // treat as not-found
106+
return result, false, err
107+
}
108+
109+
switch len(matches) {
110+
case 0:
111+
// Nothing found
112+
return result, false, nil
113+
case 1:
114+
// One cache file found
115+
if err := c.readCacheFile(matches[0], &result); err != nil {
116+
return result, false, err
93117
}
94-
return "", false, err
118+
119+
// If it's an old cache file, we rewrite the cache and delete the old file
120+
if isOldCacheFile(matches[0]) {
121+
if err := c.Set(ctx, key, result); err != nil {
122+
return result, false, errors.Wrap(err, "failed to rewrite cache in new format")
123+
}
124+
if err := os.Remove(matches[0]); err != nil {
125+
return result, false, errors.Wrap(err, "failed to remove old cache file")
126+
}
127+
}
128+
129+
return result, true, err
130+
131+
default:
132+
// More than one cache file found.
133+
// Sort them so that we'll can possibly read from the one with the most
134+
// current version.
135+
sortCacheFiles(matches)
136+
137+
newest := matches[0]
138+
toDelete := matches[1:]
139+
140+
// Read from newest
141+
if err := c.readCacheFile(newest, &result); err != nil {
142+
return result, false, err
143+
}
144+
145+
// If the newest was also an older version, we write a new version...
146+
if isOldCacheFile(newest) {
147+
if err := c.Set(ctx, key, result); err != nil {
148+
return result, false, errors.Wrap(err, "failed to rewrite cache in new format")
149+
}
150+
// ... and mark the file also as to-be-deleted
151+
toDelete = append(toDelete, newest)
152+
}
153+
154+
// Now we clean up the old ones
155+
for _, path := range toDelete {
156+
if err := os.Remove(path); err != nil {
157+
return result, false, errors.Wrap(err, "failed to remove old cache file")
158+
}
159+
}
160+
161+
return result, true, nil
95162
}
163+
}
96164

97-
// We previously cached complete ChangesetSpecs instead of just the diffs.
98-
// To be backwards compatible, we keep reading these:
99-
if strings.HasSuffix(path, ".json") {
100-
var result ChangesetSpec
101-
if err := json.Unmarshal(data, &result); err != nil {
165+
// sortCacheFiles sorts cache file paths by their "version", so that files
166+
// ending in `cacheFileExt` are first.
167+
func sortCacheFiles(paths []string) {
168+
sort.Slice(paths, func(i, j int) bool {
169+
return !isOldCacheFile(paths[i]) && isOldCacheFile(paths[j])
170+
})
171+
}
172+
173+
func isOldCacheFile(path string) bool { return !strings.HasSuffix(path, cacheFileExt) }
174+
175+
func (c ExecutionDiskCache) readCacheFile(path string, result *ExecutionResult) error {
176+
data, err := ioutil.ReadFile(path)
177+
if err != nil {
178+
return err
179+
}
180+
181+
switch {
182+
case strings.HasSuffix(path, ".v3.json"):
183+
// v3 of the cache: we cache the diff and the outputs produced by the step.
184+
if err := json.Unmarshal(data, result); err != nil {
185+
// Delete the invalid data to avoid causing an error for next time.
186+
if err := os.Remove(path); err != nil {
187+
return errors.Wrap(err, "while deleting cache file with invalid JSON")
188+
}
189+
return errors.Wrapf(err, "reading cache file %s", path)
190+
}
191+
return nil
192+
193+
case strings.HasSuffix(path, ".diff"):
194+
// v2 of the cache: we only cached the diff, since that's the
195+
// only bit of data we were interested in.
196+
result.Diff = string(data)
197+
result.Outputs = map[string]interface{}{}
198+
// Conversion is lossy, though: we don't populate result.StepChanges.
199+
result.ChangedFiles = &StepChanges{}
200+
201+
return nil
202+
203+
case strings.HasSuffix(path, ".json"):
204+
// v1 of the cache: we cached the complete ChangesetSpec instead of just the diffs.
205+
var spec ChangesetSpec
206+
if err := json.Unmarshal(data, &spec); err != nil {
102207
// Delete the invalid data to avoid causing an error for next time.
103208
if err := os.Remove(path); err != nil {
104-
return "", false, errors.Wrap(err, "while deleting cache file with invalid JSON")
209+
return errors.Wrap(err, "while deleting cache file with invalid JSON")
105210
}
106-
return "", false, errors.Wrapf(err, "reading cache file %s", path)
211+
return errors.Wrapf(err, "reading cache file %s", path)
107212
}
108-
if len(result.Commits) != 1 {
109-
return "", false, errors.New("cached result has no commits")
213+
if len(spec.Commits) != 1 {
214+
return errors.New("cached result has no commits")
110215
}
111-
return result.Commits[0].Diff, true, nil
112-
}
113216

114-
if strings.HasSuffix(path, ".diff") {
115-
return string(data), true, nil
217+
result.Diff = spec.Commits[0].Diff
218+
result.Outputs = map[string]interface{}{}
219+
result.ChangedFiles = &StepChanges{}
220+
221+
return nil
116222
}
117223

118-
return "", false, fmt.Errorf("unknown file format for cache file %q", path)
224+
return fmt.Errorf("unknown file format for cache file %q", path)
119225
}
120226

121-
func (c ExecutionDiskCache) Set(ctx context.Context, key ExecutionCacheKey, diff string) error {
227+
func (c ExecutionDiskCache) Set(ctx context.Context, key ExecutionCacheKey, result ExecutionResult) error {
122228
path, err := c.cacheFilePath(key)
123229
if err != nil {
124230
return err
125231
}
126232

233+
raw, err := json.Marshal(&result)
234+
if err != nil {
235+
return errors.Wrap(err, "serializing execution result to JSON")
236+
}
237+
127238
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
128239
return err
129240
}
130241

131-
return ioutil.WriteFile(path, []byte(diff), 0600)
242+
return ioutil.WriteFile(path, raw, 0600)
132243
}
133244

134245
func (c ExecutionDiskCache) Clear(ctx context.Context, key ExecutionCacheKey) error {
@@ -148,11 +259,11 @@ func (c ExecutionDiskCache) Clear(ctx context.Context, key ExecutionCacheKey) er
148259
// retrieve cache entries.
149260
type ExecutionNoOpCache struct{}
150261

151-
func (ExecutionNoOpCache) Get(ctx context.Context, key ExecutionCacheKey) (diff string, found bool, err error) {
152-
return "", false, nil
262+
func (ExecutionNoOpCache) Get(ctx context.Context, key ExecutionCacheKey) (result ExecutionResult, found bool, err error) {
263+
return ExecutionResult{}, false, nil
153264
}
154265

155-
func (ExecutionNoOpCache) Set(ctx context.Context, key ExecutionCacheKey, diff string) error {
266+
func (ExecutionNoOpCache) Set(ctx context.Context, key ExecutionCacheKey, result ExecutionResult) error {
156267
return nil
157268
}
158269

0 commit comments

Comments
 (0)