github.com/9elements/firmware-action/action@v0.0.0-20240514065043-044ed91c9ed8/recipes/config.go (about)

     1  // SPDX-License-Identifier: MIT
     2  
     3  // Package recipes / config
     4  package recipes
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"log/slog"
    12  	"os"
    13  	"path/filepath"
    14  	"strings"
    15  
    16  	"dagger.io/dagger"
    17  	"github.com/9elements/firmware-action/action/container"
    18  	"github.com/9elements/firmware-action/action/logging"
    19  	"github.com/go-playground/validator/v10"
    20  )
    21  
    22  // ErrVerboseJSON is raised when JSONVerboseError can't find location of problem in JSON configuration file
    23  var ErrVerboseJSON = errors.New("unable to pinpoint the problem in JSON file")
    24  
    25  // =================
    26  //  Data structures
    27  // =================
    28  
    29  // CommonOpts is common to all targets
    30  // Used to store data from githubaction.Action
    31  // For details see action.yml
    32  // ANCHOR: CommonOpts
    33  type CommonOpts struct {
    34  	// Specifies the container toolchain tag to use when building the image.
    35  	// This has an influence on the IASL, GCC and host GCC version that is used to build
    36  	//   the target. You must match the source level and sdk_version.
    37  	// NOTE: Updating the sdk_version might result in different binaries using the
    38  	//   same source code.
    39  	// Examples:
    40  	//   https://ghcr.io/9elements/firmware-action/coreboot_4.19:main
    41  	//   https://ghcr.io/9elements/firmware-action/coreboot_4.19:latest
    42  	//   https://ghcr.io/9elements/firmware-action/edk2-stable202111:latest
    43  	// See https://github.com/orgs/9elements/packages
    44  	SdkURL string `json:"sdk_url" validate:"required"`
    45  
    46  	// Gives the (relative) path to the target (firmware) repository.
    47  	// If the current repository contains the selected target, specify: '.'
    48  	// Otherwise the path should point to the target (firmware) repository submodule that
    49  	//   had been previously checked out.
    50  	RepoPath string `json:"repo_path" validate:"required,dirpath"`
    51  
    52  	// Specifies the (relative) paths to directories where are produced files (inside Container).
    53  	ContainerOutputDirs []string `json:"container_output_dirs" validate:"dive,dirpath"`
    54  
    55  	// Specifies the (relative) paths to produced files (inside Container).
    56  	ContainerOutputFiles []string `json:"container_output_files" validate:"dive,filepath"`
    57  
    58  	// Specifies the (relative) path to directory into which place the produced files.
    59  	//   Directories listed in ContainerOutputDirs and files listed in ContainerOutputFiles
    60  	//   will be exported here.
    61  	// Example:
    62  	//   Following setting:
    63  	//     ContainerOutputDirs = []string{"Build/"}
    64  	//     ContainerOutputFiles = []string{"coreboot.rom", "defconfig"}
    65  	//     OutputDir = "myOutput"
    66  	//   Will result in:
    67  	//     myOutput/
    68  	//     ├── Build/
    69  	//     ├── coreboot.rom
    70  	//     └── defconfig
    71  	OutputDir string `json:"output_dir" validate:"required,dirpath"`
    72  }
    73  
    74  // ANCHOR_END: CommonOpts
    75  
    76  // GetArtifacts returns list of wanted artifacts from container
    77  func (opts CommonOpts) GetArtifacts() *[]container.Artifacts {
    78  	var artifacts []container.Artifacts
    79  
    80  	// Directories
    81  	for _, pathDir := range opts.ContainerOutputDirs {
    82  		artifacts = append(artifacts, container.Artifacts{
    83  			ContainerPath: filepath.Join(ContainerWorkDir, pathDir),
    84  			ContainerDir:  true,
    85  			HostPath:      opts.OutputDir,
    86  			HostDir:       true,
    87  		})
    88  	}
    89  
    90  	// Files
    91  	for _, pathFile := range opts.ContainerOutputFiles {
    92  		artifacts = append(artifacts, container.Artifacts{
    93  			ContainerPath: filepath.Join(ContainerWorkDir, pathFile),
    94  			ContainerDir:  false,
    95  			HostPath:      opts.OutputDir,
    96  			HostDir:       true,
    97  		})
    98  	}
    99  
   100  	return &artifacts
   101  }
   102  
   103  // Config is for storing parsed configuration file
   104  type Config struct {
   105  	// defined in coreboot.go
   106  	Coreboot map[string]CorebootOpts `json:"coreboot" validate:"dive"`
   107  
   108  	// defined in linux.go
   109  	Linux map[string]LinuxOpts `json:"linux" validate:"dive"`
   110  
   111  	// defined in edk2.go
   112  	Edk2 map[string]Edk2Opts `json:"edk2" validate:"dive"`
   113  
   114  	// defined in stitching.go
   115  	FirmwareStitching map[string]FirmwareStitchingOpts `json:"firmware_stitching" validate:"dive"`
   116  }
   117  
   118  // AllModules method returns slice with all modules
   119  func (c Config) AllModules() map[string]FirmwareModule {
   120  	modules := make(map[string]FirmwareModule)
   121  	for key, value := range c.Coreboot {
   122  		modules[key] = value
   123  	}
   124  	for key, value := range c.Linux {
   125  		modules[key] = value
   126  	}
   127  	for key, value := range c.Edk2 {
   128  		modules[key] = value
   129  	}
   130  	for key, value := range c.FirmwareStitching {
   131  		modules[key] = value
   132  	}
   133  	return modules
   134  }
   135  
   136  // FirmwareModule interface
   137  type FirmwareModule interface {
   138  	GetDepends() []string
   139  	GetArtifacts() *[]container.Artifacts
   140  	buildFirmware(ctx context.Context, client *dagger.Client, dockerfileDirectoryPath string) (*dagger.Container, error)
   141  }
   142  
   143  // ===========
   144  //  Functions
   145  // ===========
   146  
   147  // ValidateConfig is used to validate the configuration struct read out of JSON file
   148  func ValidateConfig(conf Config) error {
   149  	// https://github.com/go-playground/validator/blob/master/_examples/struct-level/main.go
   150  	validate := validator.New(validator.WithRequiredStructEnabled())
   151  
   152  	err := validate.Struct(conf)
   153  	if err != nil {
   154  		err = errors.Join(ErrFailedValidation, err)
   155  		slog.Error(
   156  			"Configuration file failed validation",
   157  			slog.String("suggestion", "Double check the used configuration file"),
   158  			slog.Any("error", err),
   159  		)
   160  		return err
   161  	}
   162  	return nil
   163  }
   164  
   165  // ReadConfig is for reading and parsing JSON configuration file into Config struct
   166  func ReadConfig(filepath string) (*Config, error) {
   167  	// Read JSON file
   168  	content, err := os.ReadFile(filepath)
   169  	if err != nil {
   170  		slog.Error(
   171  			fmt.Sprintf("Unable to open the configuration file '%s'", filepath),
   172  			slog.Any("error", err),
   173  		)
   174  		return nil, err
   175  	}
   176  
   177  	// Expand environment variables
   178  	contentStr := string(content)
   179  	contentStr = os.ExpandEnv(contentStr)
   180  
   181  	// Decode JSON
   182  	jsonDecoder := json.NewDecoder(strings.NewReader(contentStr))
   183  	jsonDecoder.DisallowUnknownFields()
   184  	// jsonDecoder will return error when contentStr has keys not matching fields in Config struct
   185  	var payload Config
   186  	err = jsonDecoder.Decode(&payload)
   187  	if err != nil {
   188  		JSONVerboseError(contentStr, err)
   189  		return nil, err
   190  	}
   191  
   192  	// Validate config
   193  	err = ValidateConfig(payload)
   194  	if err != nil {
   195  		// no slog.Error because already called in ValidateConfig
   196  		return nil, err
   197  	}
   198  
   199  	return &payload, nil
   200  }
   201  
   202  // WriteConfig is for writing Config struct into JSON configuration file
   203  func WriteConfig(filepath string, config *Config) error {
   204  	// Generate JSON
   205  	b, err := json.MarshalIndent(config, "", "  ")
   206  	if err != nil {
   207  		slog.Error(
   208  			"Unable to convert the configuration into a JSON string",
   209  			slog.String("suggestion", logging.ThisShouldNotHappenMessage),
   210  			slog.Any("error", err),
   211  		)
   212  		return err
   213  	}
   214  
   215  	// Write JSON to file
   216  	if err := os.WriteFile(filepath, b, 0o666); err != nil {
   217  		slog.Error(
   218  			"Failed to write configuration into JSON file",
   219  			slog.Any("error", err),
   220  		)
   221  		return err
   222  	}
   223  
   224  	return nil
   225  }
   226  
   227  // JSONVerboseError is for getting more information out of json.Unmarshal() or Decoder.Decode()
   228  //
   229  //	Inspiration:
   230  //	- https://adrianhesketh.com/2017/03/18/getting-line-and-character-positions-from-gos-json-unmarshal-errors/
   231  //	Docs:
   232  //	- https://pkg.go.dev/encoding/json#Unmarshal
   233  func JSONVerboseError(jsonString string, err error) {
   234  	if jsonError, ok := err.(*json.SyntaxError); ok {
   235  		// JSON-encoded data contain a syntax error
   236  		line, character, _ := offsetToLineNumber(jsonString, int(jsonError.Offset))
   237  		slog.Error(
   238  			// https://pkg.go.dev/encoding/json#SyntaxError
   239  			fmt.Sprintf("Syntax error at line %d, character %d", line, character),
   240  			slog.Any("error", jsonError.Error()),
   241  		)
   242  		return
   243  	}
   244  	if jsonError, ok := err.(*json.UnmarshalTypeError); ok {
   245  		// JSON value is not appropriate for a given target type
   246  		line, character, _ := offsetToLineNumber(jsonString, int(jsonError.Offset))
   247  		slog.Error(
   248  			fmt.Sprintf(
   249  				"Expected type '%v', JSON contains field '%v' in struct '%s' instead (full path: %s), see line %d, character %d",
   250  				// https://pkg.go.dev/encoding/json#UnmarshalTypeError
   251  				jsonError.Type.Name(), // Go type
   252  				jsonError.Value,       // JSON field type
   253  				jsonError.Struct,      // Name of struct type containing the field
   254  				jsonError.Field,       // the full path from root node to the field
   255  				line,
   256  				character,
   257  			),
   258  			slog.Any("error", jsonError.Error()),
   259  		)
   260  		return
   261  	}
   262  	slog.Error(
   263  		"Sorry but could not pinpoint specific location of the problem in the JSON configuration file",
   264  		slog.Any("error", err),
   265  	)
   266  }
   267  
   268  func offsetToLineNumber(input string, offset int) (line int, character int, err error) {
   269  	// NOTE: I do not take into account windows line endings
   270  	//       I can't be bothered, the worst case is that with windows line-endings the character counter
   271  	//       will be off by 1, which is a sacrifice I am willing to make
   272  
   273  	if offset > len(input) || offset < 0 {
   274  		err = fmt.Errorf("offset is out of bounds for given string: %w", ErrVerboseJSON)
   275  		slog.Warn(
   276  			"Failed to pinpoint exact location of error in JSON configuration file",
   277  			slog.Any("error", err),
   278  		)
   279  		return 0, 0, err
   280  	}
   281  
   282  	line = 1
   283  	character = 0
   284  	for index, char := range input {
   285  		if char == '\n' {
   286  			line++
   287  			character = 0
   288  			continue
   289  		}
   290  		character++
   291  		if index >= offset {
   292  			break
   293  		}
   294  	}
   295  
   296  	return
   297  }