Skip to content

Commit

Permalink
Add YAML model structs for manifest files
Browse files Browse the repository at this point in the history
Part of #191.

These structs will represent the contents of the new "manifest" file,
which contains all the information we'll need in the future to cleanly
upgrade a template output.

Example manifest file:

```
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
inputs:
  - name: 'my_input_1'
    value: 'my_value_1'
  - name: 'my_input_2'
    value: 'my_value_2'
output_hashes:
  - file: 'a/b/c.txt'
    hash: 'h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c'
  - file: 'd/e/f.txt'
    hash: 'h1:7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730'
```

Some of the field names have been changed since the design doc because
the old names seemed unclear.
  • Loading branch information
drevell committed Sep 6, 2023
1 parent 1793fff commit 98e139d
Show file tree
Hide file tree
Showing 11 changed files with 412 additions and 22 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/mattn/go-isatty v0.0.19
github.com/posener/complete/v2 v2.1.0
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63
golang.org/x/mod v0.12.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
Expand Down
114 changes: 114 additions & 0 deletions templates/model/manifest/manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2023 The Authors (see AUTHORS file)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package manifest

import (
"errors"
"io"

"github.com/abcxyz/abc/templates/model"
"gopkg.in/yaml.v3"
)

// Decode unmarshals the YAML Spec from r. This function exists so we can
// validate the Spec model before providing it to the caller; we don't want the
// caller to forget, and thereby introduce bugs.
//
// If the Spec parses successfully but then fails validation, the spec will be
// returned along with the validation error.
func Decode(r io.Reader) (*Manifest, error) {
out := &Manifest{}
if err := model.DecodeAndValidate(r, out); err != nil {
return nil, err //nolint:wrapcheck
}
return out, nil
}

// Manifest represents the contents of a manifest file. A manifest file is the
// set of all information that is needed to cleanly upgrade to a new template
// version in the future.
type Manifest struct {
Pos model.ConfigPos `yaml:"-"`

APIVersion model.String `yaml:"api_version"`
TemplateLocation model.String `yaml:"template_location"`
TemplateDirhash model.String `yaml:"template_dirhash"`
Inputs []*Input `yaml:"inputs"`
OutputHashes []*FileHash `yaml:"output_hashes"`
}

// UnmarshalYAML implements yaml.Unmarshaler.
func (m *Manifest) UnmarshalYAML(n *yaml.Node) error {
return model.UnmarshalPlain(n, m, &m.Pos) //nolint:wrapcheck
}

// Validate() implements model.Validator.
func (m *Manifest) Validate() error {
// We don't validate the TemplateDirhash here beyond just verifying that
// it's present; it's validated more later when it's used.
//
// Inputs and OutputHashes can legally be empty.
return errors.Join(
model.IsKnownSchemaVersion(&m.Pos, m.APIVersion, "api_version"),
model.NotZeroModel(&m.Pos, m.TemplateLocation, "template_location"),
model.NotZeroModel(&m.Pos, m.TemplateDirhash, "template_dirhash"),
model.ValidateEach(m.Inputs),
model.ValidateEach(m.OutputHashes),
)
}

// Input is a YAML object representing an input value that was provided to the
// template when it was rendered.
type Input struct {
Pos model.ConfigPos

Name model.String `yaml:"name"`
Value model.String `yaml:"value"`
}

// UnmarshalYAML implements yaml.Unmarshaler.
func (i *Input) UnmarshalYAML(n *yaml.Node) error {
return model.UnmarshalPlain(n, i, &i.Pos) //nolint:wrapcheck
}

// Validate() implements model.Validator.
func (i *Input) Validate() error {
return errors.Join(
model.NotZeroModel(&i.Pos, i.Name, "name"),
model.NotZeroModel(&i.Pos, i.Value, "value"),
)
}

// FileHash records a checksum of a single file as it was created during
// template rendering.
type FileHash struct {
Pos model.ConfigPos

File model.String `yaml:"file"`
Hash model.String `yaml:"hash"` // A dirhash-style hash, see https://pkg.go.dev/golang.org/x/mod/sumdb/dirhash
}

// UnmarshalYAML implements yaml.Unmarshaler.
func (f *FileHash) UnmarshalYAML(n *yaml.Node) error {
return model.UnmarshalPlain(n, f, &f.Pos) //nolint:wrapcheck
}

// Validate() implements model.Validator.
func (f *FileHash) Validate() error {
return errors.Join(
model.NotZeroModel(&f.Pos, f.File, "file"),
model.NotZeroModel(&f.Pos, f.Hash, "hash"),
)
}
228 changes: 228 additions & 0 deletions templates/model/manifest/manifest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Copyright 2023 The Authors (see AUTHORS file)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package manifest

import (
"io"
"strings"
"testing"

"github.com/abcxyz/abc/templates/model"
"github.com/abcxyz/pkg/testutil"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"gopkg.in/yaml.v3"
)

func TestDecode(t *testing.T) {
t.Parallel()

cases := []struct {
name string
in string
want *Manifest
wantUnmarshalErr string
wantValidateErr string
}{
{
name: "simple-success",
in: `
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
inputs:
- name: 'my_input_1'
value: 'my_value_1'
- name: 'my_input_2'
value: 'my_value_2'
output_hashes:
- file: 'a/b/c.txt'
hash: 'h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c'
- file: 'd/e/f.txt'
hash: 'h1:7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730'`,
want: &Manifest{
APIVersion: model.String{Val: "cli.abcxyz.dev/v1alpha1"},
TemplateLocation: model.String{Val: "github.com/abcxyz/abc.git//t/rest_server"},
TemplateDirhash: model.String{Val: "h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"},
Inputs: []*Input{
{
Name: model.String{Val: "my_input_1"},
Value: model.String{Val: "my_value_1"},
},
{
Name: model.String{Val: "my_input_2"},
Value: model.String{Val: "my_value_2"},
},
},
OutputHashes: []*FileHash{
{
File: model.String{Val: "a/b/c.txt"},
Hash: model.String{Val: "h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"},
},
{
File: model.String{Val: "d/e/f.txt"},
Hash: model.String{Val: "h1:7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730"},
},
},
},
},
{
name: "fields-missing",
in: `api_version: "foo"`,
wantValidateErr: `at line 1 column 14: field "api_version" value must be one of [cli.abcxyz.dev/v1alpha1]
at line 1 column 1: field "template_location" is required
at line 1 column 1: field "template_dirhash" is required`,
},
{
name: "input-missing-name",
in: `
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
inputs:
- value: 'my_value_1'
output_hashes:
- file: 'a/b/c.txt'
hash: 'h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c'`,
wantValidateErr: `at line 6 column 5: field "name" is required`,
},
{
name: "input-missing-value",
in: `
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
inputs:
- name: 'my_input_1'
output_hashes:
- file: 'a/b/c.txt'
hash: 'h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c'`,
wantValidateErr: `at line 6 column 5: field "value" is required`,
},
{
name: "output-hash-missing-file",
in: `
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
inputs:
- name: 'my_input_1'
value: 'my_value_1'
output_hashes:
- hash: 'h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c'`,
wantValidateErr: `at line 9 column 5: field "file" is required`,
},
{
name: "output-hash-missing-file",
in: `
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
inputs:
- name: 'my_input_1'
value: 'my_value_1'
output_hashes:
- file: 'a/b/c.txt'`,
wantValidateErr: `at line 9 column 5: field "hash" is required`,
},
{
name: "no-hashes", // It's rare but legal for a template to have no output files
in: `
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
inputs:
- name: 'my_input_1'
value: 'my_value_1'
`,
want: &Manifest{
APIVersion: model.String{Val: "cli.abcxyz.dev/v1alpha1"},
TemplateLocation: model.String{Val: "github.com/abcxyz/abc.git//t/rest_server"},
TemplateDirhash: model.String{Val: "h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"},
Inputs: []*Input{
{
Name: model.String{Val: "my_input_1"},
Value: model.String{Val: "my_value_1"},
},
},
},
},
{
name: "no-inputs", // It's legal for a template to have no inputs
in: `
api_version: 'cli.abcxyz.dev/v1alpha1'
template_location: 'github.com/abcxyz/abc.git//t/rest_server'
template_dirhash: 'h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
output_hashes:
- file: 'a/b/c.txt'
hash: 'h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c'`,
want: &Manifest{
APIVersion: model.String{Val: "cli.abcxyz.dev/v1alpha1"},
TemplateLocation: model.String{Val: "github.com/abcxyz/abc.git//t/rest_server"},
TemplateDirhash: model.String{Val: "h1:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"},
OutputHashes: []*FileHash{
{
File: model.String{Val: "a/b/c.txt"},
Hash: model.String{Val: "h1:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"},
},
},
},
},
{
name: "bad-yaml-syntax",
in: `[[[[[[[`,
wantUnmarshalErr: "did not find expected node content",
},
}

for _, tc := range cases {
tc := tc

t.Run(tc.name, func(t *testing.T) {
t.Parallel()

got := &Manifest{}
dec := newDecoder(strings.NewReader(tc.in))
err := dec.Decode(got)

if diff := testutil.DiffErrString(err, tc.wantUnmarshalErr); diff != "" {
t.Fatal(diff)
}
if err != nil {
return
}

err = got.Validate()
if diff := testutil.DiffErrString(err, tc.wantValidateErr); diff != "" {
t.Fatal(diff)
}
if err != nil {
return
}

opt := cmpopts.IgnoreTypes(&model.ConfigPos{}, model.ConfigPos{}) // don't force test authors to assert the line and column numbers
if diff := cmp.Diff(got, tc.want, opt); diff != "" {
t.Errorf("unmarshaling didn't yield expected struct. Diff (-got +want): %s", diff)
}
})
}
}

// newDecoder returns a yaml Decoder with the desired options.
func newDecoder(r io.Reader) *yaml.Decoder {
dec := yaml.NewDecoder(r)
dec.KnownFields(true) // Fail if any unexpected fields are seen. Often doesn't work: https://github.com/go-yaml/yaml/issues/460
return dec
}
6 changes: 5 additions & 1 deletion templates/model/pos.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type ConfigPos struct {
Column int
}

func (c ConfigPos) IsZero() bool {
return c == ConfigPos{}
}

// YAMLPos constructs a position struct based on a YAML parse cursor.
func YAMLPos(n *yaml.Node) *ConfigPos {
return &ConfigPos{
Expand All @@ -51,7 +55,7 @@ func YAMLPos(n *yaml.Node) *ConfigPos {
// Creating an error: c.Errorf("something went wrong doing action %s", action)
func (c *ConfigPos) Errorf(fmtStr string, args ...any) error {
err := fmt.Errorf(fmtStr, args...)
if c == nil || *c == (ConfigPos{}) {
if c == nil || c.IsZero() {
return err
}

Expand Down
Loading

0 comments on commit 98e139d

Please sign in to comment.