diff --git a/internals/plan/export_test.go b/internals/plan/export_test.go new file mode 100644 index 00000000..9da16930 --- /dev/null +++ b/internals/plan/export_test.go @@ -0,0 +1,17 @@ +// Copyright (c) 2024 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package plan + +var LayerBuiltins = layerBuiltins diff --git a/internals/plan/plan.go b/internals/plan/plan.go index ddb90fdf..1787b1eb 100644 --- a/internals/plan/plan.go +++ b/internals/plan/plan.go @@ -21,6 +21,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "strconv" "strings" "time" @@ -70,11 +71,15 @@ const ( // layerExtensions keeps a map of registered extensions. var layerExtensions = map[string]LayerSectionExtension{} +// layerBuiltins represents all the built-in layer sections. This list is used +// for identifying built-in fields in this package. It is unit tested to match +// the YAML fields exposed in the Layer type, to catch inconsistencies. +var layerBuiltins = []string{"summary", "description", "services", "checks", "log-targets"} + // RegisterExtension adds a plan schema extension. All registrations must be // done before the plan library is used. func RegisterExtension(field string, ext LayerSectionExtension) { - switch field { - case "summary", "description", "services", "checks", "log-targets": + if slices.Contains(layerBuiltins, field) { panic(fmt.Sprintf("internal error: extension %q already used as built-in field", field)) } if _, ok := layerExtensions[field]; ok { @@ -1226,6 +1231,11 @@ func ParseLayer(order int, label string, data []byte) (*Layer, error) { "checks": &layer.Checks, "log-targets": &layer.LogTargets, } + // Make sure builtinSections contains the exact same fields as expected + // in the Layer type. + if !mapHasKeys(builtinSections, layerBuiltins) { + panic("internal error: parsed fields and layer fields differ") + } layerSections := make(map[string]yaml.Node) // Deliberately pre-allocate at least an empty yaml.Node for every @@ -1245,7 +1255,7 @@ func ParseLayer(order int, label string, data []byte) (*Layer, error) { } for field, section := range layerSections { - if _, builtin := builtinSections[field]; builtin { + if slices.Contains(layerBuiltins, field) { // The following issue prevents us from using the yaml.Node decoder // with KnownFields = true behaviour. Once one of the proposals get // merged, we can remove the intermediate Marshal step. @@ -1312,6 +1322,20 @@ func ParseLayer(order int, label string, data []byte) (*Layer, error) { return layer, err } +// mapHasKeys returns true if the key list supplied is an exact match of the +// keys in the map (ordering is ignored). +func mapHasKeys[M ~map[K]V, K comparable, V any](inMap M, keyList []K) bool { + if len(inMap) != len(keyList) { + return false + } + for _, key := range keyList { + if _, ok := inMap[key]; !ok { + return false + } + } + return true +} + func validServiceAction(action ServiceAction, additionalValid ...ServiceAction) bool { for _, v := range additionalValid { if action == v { diff --git a/internals/plan/plan_test.go b/internals/plan/plan_test.go index fbc1fb96..4e119e2c 100644 --- a/internals/plan/plan_test.go +++ b/internals/plan/plan_test.go @@ -19,8 +19,11 @@ import ( "fmt" "os" "path/filepath" + "reflect" + "slices" "strings" "time" + "unicode" . "gopkg.in/check.v1" "gopkg.in/yaml.v3" @@ -2068,3 +2071,41 @@ func (s *S) TestStartStopOrderMultipleLanes(c *C) { c.Assert(lanes[1], DeepEquals, []string{"srv2"}) c.Assert(lanes[2], DeepEquals, []string{"srv3"}) } + +// TestLayerBuiltinCompatible ensures layerBuiltins used in the plan package +// reflects the same YAML fields as exposed in the Layer type. +func (s *S) TestLayerBuiltinCompatible(c *C) { + fields := structYamlFields(plan.Layer{}) + c.Assert(len(fields), Equals, len(plan.LayerBuiltins)) + for _, field := range structYamlFields(plan.Layer{}) { + c.Assert(slices.Contains(plan.LayerBuiltins, field), Equals, true) + } +} + +// structYamlFields extracts the YAML fields from a struct. If the YAML tag +// is omitted, the field name with the first letter lower case will be used. +func structYamlFields(inStruct any) []string { + var fields []string + inStructType := reflect.TypeOf(inStruct) + for i := range inStructType.NumField() { + fieldType := inStructType.Field(i) + yamlTag := fieldType.Tag.Get("yaml") + if fieldType.IsExported() && yamlTag != "-" && !strings.Contains(yamlTag, ",inline") { + tag, _, _ := strings.Cut(fieldType.Tag.Get("yaml"), ",") + if tag == "" { + tag = firstLetterToLower(fieldType.Name) + } + fields = append(fields, tag) + } + } + return fields +} + +func firstLetterToLower(s string) string { + if len(s) == 0 { + return s + } + r := []rune(s) + r[0] = unicode.ToLower(r[0]) + return string(r) +}