...

Source file src/edge-infra.dev/pkg/tools/dlog/decision_log.go

Documentation: edge-infra.dev/pkg/tools/dlog

     1  // Package dlog implements functionality for working with structured decision
     2  // logs in the spirit of MADR (https://adr.github.io/madr).
     3  package dlog
     4  
     5  import (
     6  	"bufio"
     7  	"bytes"
     8  	"errors"
     9  	"fmt"
    10  	"io/fs"
    11  	"os"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strings"
    15  	"time"
    16  
    17  	"sigs.k8s.io/yaml"
    18  )
    19  
    20  const (
    21  	nameRegexStr = `\d{3,}(-\w+)*\.md`
    22  	delim        = "---"
    23  	h1           = "#"
    24  )
    25  
    26  // Decision log status phases.
    27  const (
    28  	StatusProposed   = "proposed"
    29  	StatusRejected   = "rejected"
    30  	StatusAccepted   = "accepted"
    31  	StatusDeprecated = "deprecated"
    32  	StatusSuperseded = "superseded"
    33  )
    34  
    35  var nameRegex = regexp.MustCompile(nameRegexStr)
    36  
    37  // Decision represents the structured data associated with a decision log,
    38  // typically stored in the frontmatter of the markdown file containing the
    39  // decision log, e.g.:
    40  //
    41  //	# 0021-my-teams-decision.md
    42  //	---
    43  //	status: proposed
    44  //	date: '10-28-30'
    45  //	[...]
    46  //	---
    47  //
    48  //	# $DECISION_LOG_TITLE
    49  //	[...]
    50  type Decision struct {
    51  	// Status communicates the phase of the decision's lifecycle. It must be one
    52  	// of: "proposed", "rejected", "accepted", "deprecated",
    53  	// "superseded by [link to decision]"
    54  	Status string `json:"status"`
    55  
    56  	// Date is the last time this decision was updated, in the format YYYY-MM-DD
    57  	Date Time `json:"date"`
    58  
    59  	// Deciders is a required list of individuals or teams that were involved in
    60  	// the final outcome for the decision.
    61  	Deciders []string `json:"deciders"`
    62  
    63  	// Tags are optional metadata used for categorizing and filtering decisions.
    64  	Tags []string `json:"tags,omitempty"`
    65  
    66  	// Consulted is an optional list of individuals or teams that were consulted
    67  	// by the deciders.
    68  	Consulted []string `json:"consulted,omitempty"`
    69  
    70  	// Informed is an optional list of individuals or teams that were kept
    71  	// up-to-date on the decision.
    72  	Informed []string `json:"informed,omitempty"`
    73  
    74  	// Information populated on load, exposed via methods to avoid being
    75  	// serialized.
    76  	file   string
    77  	number string
    78  	title  string
    79  }
    80  
    81  // Number is the unique identifier for the decision log, populated on load,
    82  // e.g.: 0003-my-decision.md would have the number '0003'
    83  func (d *Decision) Number() string { return d.number }
    84  
    85  // File is the name of the file containing the decision log, populated on load.
    86  func (d *Decision) File() string { return d.file }
    87  
    88  // Title returns the decision log doc title, the first '#' header, e.g.:
    89  // '# 0003: My Decision' would have the title 'My Decision'.
    90  func (d *Decision) Title() string { return d.title }
    91  
    92  // Time wraps [time.Time] so that we can unmarshal YYYY-MM-DD format dates.
    93  type Time struct {
    94  	time.Time
    95  }
    96  
    97  // UnmarshalJSON implements [json.Unmarshaller] so that we can control specifics
    98  // of the time format.
    99  func (t *Time) UnmarshalJSON(b []byte) error {
   100  	s := strings.Trim(string(b), "\"")
   101  	if s == "null" {
   102  		t.Time = time.Time{}
   103  		return nil
   104  	}
   105  	var err error
   106  	t.Time, err = time.Parse(time.DateOnly, s)
   107  	return err
   108  }
   109  
   110  // MarshalJSON implements [json.Marshaller] so that we can control formatting
   111  // when we write Decision data to file.
   112  func (t *Time) MarshalJSON() ([]byte, error) {
   113  	return []byte(`"` + t.Format(time.DateOnly) + `"`), nil
   114  }
   115  
   116  func (t Time) String() string {
   117  	return t.Format(time.DateOnly)
   118  }
   119  
   120  // IsValid returns an error if the decision is missing required fields or has
   121  // invalid values for any fields.
   122  func (d *Decision) IsValid() error {
   123  	if d.Status == "" {
   124  		return fmt.Errorf("status is required")
   125  	}
   126  
   127  	if d.Status != StatusProposed && d.Status != StatusRejected &&
   128  		d.Status != StatusAccepted && d.Status != StatusDeprecated &&
   129  		!strings.HasPrefix(d.Status, StatusSuperseded) {
   130  		return fmt.Errorf("invalid value for status: %s, expected one of: "+
   131  			"'accepted', 'proposed', 'rejected', 'deprecated', 'superseded by [...]'",
   132  			d.Status)
   133  	}
   134  
   135  	if d.Date.IsZero() {
   136  		return fmt.Errorf("date is required")
   137  	}
   138  
   139  	if len(d.Deciders) == 0 {
   140  		return fmt.Errorf("deciders is required")
   141  	}
   142  
   143  	return nil
   144  }
   145  
   146  // IsValidFileName returns an error if the decision log path is not in the
   147  // expected format.
   148  func IsValidFileName(path string) error {
   149  	if nameRegex.MatchString(path) {
   150  		return nil
   151  	}
   152  	return fmt.Errorf("%s is an invalid name for a decision log: decision log "+
   153  		"file names should match the expression '%s'", path, nameRegexStr)
   154  }
   155  
   156  // FromMarkdown parses decision data from frontmatter embedded in a markdown
   157  // file.
   158  func FromMarkdown(path string) (*Decision, error) {
   159  	if err := IsValidFileName(path); err != nil {
   160  		return nil, err
   161  	}
   162  
   163  	return fromMarkdown(path)
   164  }
   165  
   166  func fromMarkdown(path string) (*Decision, error) {
   167  	file, err := os.Open(path)
   168  	if err != nil {
   169  		return nil, fmt.Errorf("failed to read %s: %w", path, err)
   170  	}
   171  	defer file.Close()
   172  
   173  	filename := filepath.Base(path)
   174  	// NOTE: We know this is safe because we have validated the path ahead of time
   175  	number := strings.Split(filename, "-")[0]
   176  
   177  	scanner := bufio.NewScanner(file)
   178  	// Frontmatter should come first, so check the first line before iterating
   179  	scanner.Scan()
   180  	if scanner.Text() != delim {
   181  		return nil, fmt.Errorf("%s doesn't contain decision log frontmatter", path)
   182  	}
   183  	// Read frontmatter from file
   184  	data := make([]byte, 0)
   185  	for scanner.Scan() {
   186  		text := scanner.Text()
   187  		// We have reached the ending delimiter
   188  		if text == delim {
   189  			break
   190  		}
   191  		// We are slurpin' til we hit the end
   192  		data = append(data, []byte(text+"\n")...)
   193  	}
   194  	if scanner.Err() != nil {
   195  		return nil, fmt.Errorf("%s: error reading decision log frontmatter: %w",
   196  			path, err)
   197  	}
   198  
   199  	d := &Decision{file: filename, number: number}
   200  	if err := yaml.Unmarshal(data, d); err != nil {
   201  		return nil, fmt.Errorf("failed to parse decision data from %s: %w", path, err)
   202  	}
   203  
   204  	// Read title from first H1 in the document
   205  	for scanner.Scan() {
   206  		text := scanner.Text()
   207  		if title, found := strings.CutPrefix(text, h1); found {
   208  			title = strings.TrimSpace(title)
   209  			// Dlogs are expected to be titled `# $NUMBER: $TITLE`
   210  			if !strings.HasPrefix(title, fmt.Sprintf("%s: ", d.number)) {
   211  				return nil, fmt.Errorf("%s: unexpected title %s, titles should be in "+
   212  					"the format '$NUMBER: $TITLE'", path, title)
   213  			}
   214  			d.title = strings.TrimSpace(strings.Split(title, ":")[1])
   215  			break
   216  		}
   217  	}
   218  
   219  	// Validate that loaded decision is well-formed
   220  	if err := d.IsValid(); err != nil {
   221  		return nil, fmt.Errorf("decision data from %s is invalid: %w", path, err)
   222  	}
   223  
   224  	return d, nil
   225  }
   226  
   227  // FromDir loads all decision logs under path recursively. Any files that don't
   228  // have a valid name or contain decision log frontmatter are ignored.
   229  func FromDir(dir string) ([]*Decision, error) {
   230  	dd := make([]*Decision, 0)
   231  
   232  	if err := fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, _ error) error {
   233  		switch {
   234  		case d.IsDir(), IsValidFileName(path) != nil:
   235  			return nil
   236  		}
   237  		dec, err := fromMarkdown(filepath.Join(dir, path))
   238  		if err != nil {
   239  			return err
   240  		}
   241  		dd = append(dd, dec)
   242  		return nil
   243  	}); err != nil {
   244  		return nil, err
   245  	}
   246  
   247  	return dd, nil
   248  }
   249  
   250  // ToMarkdown writes new frontmatter to a markdown file for d, creating the
   251  // file if it doesn't exist.
   252  func ToMarkdown(d *Decision, dir string) error {
   253  	var (
   254  		f *os.File
   255  		p = filepath.Join(dir, d.file)
   256  	)
   257  
   258  	_, err := os.Stat(p)
   259  	switch {
   260  	case errors.Is(err, os.ErrNotExist):
   261  		f, err = os.Create(p)
   262  		if err != nil {
   263  			return fmt.Errorf("failed to create %s: %w", p, err)
   264  		}
   265  	case err != nil:
   266  		return fmt.Errorf("failed to stat %s: %w", p, err)
   267  	default:
   268  		f, err = os.Open(p)
   269  		if err != nil {
   270  			return fmt.Errorf("failed to open %s: %w", p, err)
   271  		}
   272  	}
   273  	defer f.Close()
   274  
   275  	// Generate new frontmatter, write it to top of buffer that will become the
   276  	// updated file.
   277  	data, err := yaml.Marshal(d)
   278  	if err != nil {
   279  		return fmt.Errorf("failed to marshal decision log: %w", err)
   280  	}
   281  	buf := new(bytes.Buffer)
   282  	buf.WriteString("---\n")
   283  	buf.Write(data)
   284  	buf.WriteString("---\n")
   285  
   286  	// Scan current file, dropping any frontmatter and then writing the rest of
   287  	// the file to new buffer
   288  	scanner := bufio.NewScanner(f)
   289  	// If first call to Scan returns false but there is no error, we just created
   290  	// this file, write buffer and exit early
   291  	if !scanner.Scan() && scanner.Err() == nil {
   292  		if _, err := buf.WriteTo(f); err != nil {
   293  			return fmt.Errorf("failed to write %s: %w", p, err)
   294  		}
   295  		return nil
   296  	}
   297  
   298  	// File didn't have any existing frontmatter, we can jump into writing
   299  	// all contents to our buffer and updating the file
   300  	if scanner.Text() != delim {
   301  		if _, err := buf.ReadFrom(f); err != nil {
   302  			return fmt.Errorf("failed to read %s: %w", p, err)
   303  		}
   304  		return updateFile(f.Name(), buf)
   305  	}
   306  
   307  	// Replace existing frontmatter
   308  	slurp := false
   309  	for scanner.Scan() {
   310  		text := scanner.Text()
   311  		// When we hit second delimiter, its soup time
   312  		if text == delim {
   313  			slurp = true
   314  			continue
   315  		}
   316  		if slurp {
   317  			buf.WriteString(text)
   318  			buf.WriteString("\n")
   319  		}
   320  	}
   321  	return updateFile(f.Name(), buf)
   322  }
   323  
   324  // updateFile resets an existing file and writes contents to it
   325  func updateFile(f string, contents *bytes.Buffer) error {
   326  	file, err := os.Create(f)
   327  	if err != nil {
   328  		return fmt.Errorf("failed to reset %s: %w", f, err)
   329  	}
   330  	// Overwrite it
   331  	if _, err := contents.WriteTo(file); err != nil {
   332  		return fmt.Errorf("failed to write %s: %w", f, err)
   333  	}
   334  	return nil
   335  }
   336  

View as plain text