Skip to content

Commit

Permalink
feat(plan): add plan section support
Browse files Browse the repository at this point in the history
  • Loading branch information
flotter committed Jul 29, 2024
1 parent 6bbc91a commit 43fe543
Show file tree
Hide file tree
Showing 6 changed files with 932 additions and 57 deletions.
20 changes: 20 additions & 0 deletions internals/plan/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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 <http://www.gnu.org/licenses/>.

package plan

// ResetLayerExtensions resets the global state between tests.
func ResetLayerExtensions() {
layerExtensions = map[string]LayerSectionExtension{}
}
34 changes: 34 additions & 0 deletions internals/plan/package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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 progral. If not, see <http://www.gnu.org/licenses/>.

package plan_test

import (
"testing"

. "gopkg.in/check.v1"

"github.com/canonical/pebble/internals/plan"
)

// Hook up check.v1 into the "go test" runner.
func Test(t *testing.T) { TestingT(t) }

type planSuite struct{}

var _ = Suite(&planSuite{})

func (ps *planSuite) SetUpTest(c *C) {
plan.ResetLayerExtensions()
}
186 changes: 175 additions & 11 deletions internals/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ import (
"github.com/canonical/pebble/internals/osutil"
)

// LayerSectionExtension allows the plan layer schema to be extended without
// adding centralised schema knowledge to the plan library.
type LayerSectionExtension interface {
// ParseSection creates a new layer section containing the unmarshalled
// yaml.Node, and any additional section specifics it wishes to apply
// to the backing type. A nil LayerSection returned ensures that this
// section will be omitted by the caller.
ParseSection(data *yaml.Node) (LayerSection, error)

// CombineSections creates a new layer section containing the result of
// combining the layer sections in the supplied order. A nil LayerSection
// returned ensures the combined section will be completely omitted by
// the caller.
CombineSections(sections ...LayerSection) (LayerSection, error)

// ValidatePlan takes the complete plan as input, and allows the
// extension to validate the plan. This can be used for cross section
// dependency validation.
ValidatePlan(plan *Plan) error
}

type LayerSection interface {
// Validate expects the section to validate itself.
Validate() error
}

const (
defaultBackoffDelay = 500 * time.Millisecond
defaultBackoffFactor = 2.0
Expand All @@ -42,11 +68,33 @@ const (
defaultCheckThreshold = 3
)

// layerExtensions keeps a map of registered extensions.
var layerExtensions = map[string]LayerSectionExtension{}

// RegisterExtension must be called by the plan extension owners to
// extend the plan schema before the plan is loaded.
func RegisterExtension(field string, ext LayerSectionExtension) error {
if _, ok := layerExtensions[field]; ok {
return fmt.Errorf("internal error: extension %q already registered", field)
}
layerExtensions[field] = ext
return nil
}

type Plan struct {
Layers []*Layer `yaml:"-"`
Services map[string]*Service `yaml:"services,omitempty"`
Checks map[string]*Check `yaml:"checks,omitempty"`
LogTargets map[string]*LogTarget `yaml:"log-targets,omitempty"`

// Extended schema sections.
Sections map[string]LayerSection `yaml:",inline,omitempty"`
}

// Section retrieves a section from the plan. Returns nil if
// the field does not exist.
func (p *Plan) Section(field string) LayerSection {
return p.Sections[field]
}

type Layer struct {
Expand All @@ -57,6 +105,19 @@ type Layer struct {
Services map[string]*Service `yaml:"services,omitempty"`
Checks map[string]*Check `yaml:"checks,omitempty"`
LogTargets map[string]*LogTarget `yaml:"log-targets,omitempty"`

Sections map[string]LayerSection `yaml:",inline,omitempty"`
}

// addSection adds a new section to the layer.
func (layer *Layer) addSection(field string, section LayerSection) {
layer.Sections[field] = section
}

// Section retrieves a layer section from a layer. Returns nil if
// the field does not exist.
func (layer *Layer) Section(field string) LayerSection {
return layer.Sections[field]
}

type Service struct {
Expand Down Expand Up @@ -559,6 +620,7 @@ func CombineLayers(layers ...*Layer) (*Layer, error) {
Services: make(map[string]*Service),
Checks: make(map[string]*Check),
LogTargets: make(map[string]*LogTarget),
Sections: make(map[string]LayerSection),
}
if len(layers) == 0 {
return combined, nil
Expand Down Expand Up @@ -643,6 +705,30 @@ func CombineLayers(layers ...*Layer) (*Layer, error) {
}
}

// Combine the same sections from each layer.
for field, extension := range layerExtensions {
var sections []LayerSection
for _, layer := range layers {
if section := layer.Section(field); section != nil {
sections = append(sections, section)
}
}
// Deliberately do not expose the zero section condition to the extension.
// For now, the result of combining nothing must result in an omitted section.
if len(sections) > 0 {
combinedSection, err := extension.CombineSections(sections...)
if err != nil {
return nil, &FormatError{
Message: fmt.Sprintf(`cannot combine section %q: %v`, field, err),
}
}
// We support the ability for a valid combine to result in an omitted section.
if combinedSection != nil {
combined.addSection(field, combinedSection)
}
}
}

// Set defaults where required.
for _, service := range combined.Services {
if !service.BackoffDelay.IsSet {
Expand Down Expand Up @@ -825,11 +911,18 @@ func (layer *Layer) Validate() error {
}
}

for field, section := range layer.Sections {
err := section.Validate()
if err != nil {
return fmt.Errorf("cannot validate layer section %q: %w", field, err)
}
}

return nil
}

// Validate checks that the combined layers form a valid plan.
// See also Layer.Validate, which checks that the individual layers are valid.
// Validate checks that the combined layers form a valid plan. See also
// Layer.Validate, which checks that the individual layers are valid.
func (p *Plan) Validate() error {
for name, service := range p.Services {
if service.Command == "" {
Expand Down Expand Up @@ -917,6 +1010,15 @@ func (p *Plan) Validate() error {
if err != nil {
return err
}

// Each section extension must validate the combined plan.
for field, extension := range layerExtensions {
err = extension.ValidatePlan(p)
if err != nil {
return fmt.Errorf("cannot validate plan section %q: %w", field, err)
}
}

return nil
}

Expand Down Expand Up @@ -1020,19 +1122,80 @@ func (p *Plan) checkCycles() error {
}

func ParseLayer(order int, label string, data []byte) (*Layer, error) {
layer := Layer{
Services: map[string]*Service{},
Checks: map[string]*Check{},
LogTargets: map[string]*LogTarget{},
}
dec := yaml.NewDecoder(bytes.NewBuffer(data))
dec.KnownFields(true)
err := dec.Decode(&layer)
layer := &Layer{
Services: make(map[string]*Service),
Checks: make(map[string]*Check),
LogTargets: make(map[string]*LogTarget),
Sections: make(map[string]LayerSection),
}

// The following manual approach is required because:
//
// 1. Extended sections are YAML inlined, and also do not have a
// concrete type at this level, we cannot simply unmarshal the layer
// at once.
//
// 2. We honor KnownFields = true behaviour for non extended schema
// sections, and at the top field level, which includes Section field
// names.

builtinSections := map[string]interface{}{
"summary": &layer.Summary,
"description": &layer.Description,
"services": &layer.Services,
"checks": &layer.Checks,
"log-targets": &layer.LogTargets,
}

var layerSections map[string]yaml.Node
err := yaml.Unmarshal(data, &layerSections)
if err != nil {
return nil, &FormatError{
Message: fmt.Sprintf("cannot parse layer %q: %v", label, err),
}
}

for field, section := range layerSections {
switch field {
case "summary", "description", "services", "checks", "log-targets":
// 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 Marshall step.
// https://github.com/go-yaml/yaml/issues/460
data, err := yaml.Marshal(&section)
if err != nil {
return nil, fmt.Errorf("internal error: cannot marshal %v section: %w", field, err)
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err = dec.Decode(builtinSections[field]); err != nil {
return nil, &FormatError{
Message: fmt.Sprintf("cannot parse layer %q section %q: %v", label, field, err),
}
}
default:
if extension, ok := layerExtensions[field]; ok {
// Section unmarshal rules are defined by the extension itself.
extendedSection, err := extension.ParseSection(&section)
if err != nil {
return nil, &FormatError{
Message: fmt.Sprintf("cannot parse layer %q section %q: %v", label, field, err),
}
}
if extendedSection != nil {
layer.addSection(field, extendedSection)
}
} else {
// At the top level we do not ignore keys we do not understand.
// This preserves the current Pebble behaviour of decoding with
// KnownFields = true.
return nil, &FormatError{
Message: fmt.Sprintf("cannot parse layer %q: unknown section %q", label, field),
}
}
}
}

layer.Order = order
layer.Label = label

Expand Down Expand Up @@ -1060,7 +1223,7 @@ func ParseLayer(order int, label string, data []byte) (*Layer, error) {
return nil, err
}

return &layer, err
return layer, err
}

func validServiceAction(action ServiceAction, additionalValid ...ServiceAction) bool {
Expand Down Expand Up @@ -1164,6 +1327,7 @@ func ReadDir(dir string) (*Plan, error) {
Services: combined.Services,
Checks: combined.Checks,
LogTargets: combined.LogTargets,
Sections: combined.Sections,
}
err = plan.Validate()
if err != nil {
Expand Down
Loading

0 comments on commit 43fe543

Please sign in to comment.