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 }