|
| 1 | +# RFC 013: Configuration File |
| 2 | + |
| 3 | +## Background |
| 4 | + |
| 5 | +Contrast is currently configured exclusively via a CLI with a large number of flags and arguments across multiple subcommands. |
| 6 | +While this provides flexibility, it leads to several practical issues: |
| 7 | +- Users who want to make changes to a deployment must reconstruct long command invocations. |
| 8 | +- In team settings, these invocations must be shared out-of-band. |
| 9 | +- Configuration intent isn't easily reviewable or version-controllable. |
| 10 | + |
| 11 | +As a result, users are effectively forced to build their own configuration abstraction on top of the CLI. |
| 12 | +This RFC proposes introducing a first-class configuration file to address this, while preserving full backward compatibility with existing CLI usage. |
| 13 | + |
| 14 | +## Requirements |
| 15 | + |
| 16 | +1. Existing CLI invocations must continue to work without modification. More specifically: |
| 17 | + 1. Command-line flags and arguments must always take precedence over configuration file values. |
| 18 | + 2. Users must not be required to use a configuration file. |
| 19 | +2. The configuration format should easily be extensible for future use cases. |
| 20 | +3. The introduction of a configuration file must not significantly increase maintenance burden. |
| 21 | + |
| 22 | +## Design |
| 23 | + |
| 24 | +### Configuration format |
| 25 | + |
| 26 | +We introduce a TOML-based configuration file. |
| 27 | +TOML is chosen for its readability and explicit structure. |
| 28 | +Additionally, parts of the codebase already interact with TOML files, allowing us to avoid additional dependencies. |
| 29 | + |
| 30 | +The configuration file makes use of TOML tables for structure. |
| 31 | +At its most basic, the file only contains a `cli` table, with its keys corresponding to the existing CLI arguments: |
| 32 | + |
| 33 | +```toml |
| 34 | +[cli] |
| 35 | +# path to the policy (.rego) file |
| 36 | +policy = "./path/to/policy.rego" |
| 37 | + |
| 38 | +# path to the settings (.json) file |
| 39 | +settings = "./path/to/settings.json" |
| 40 | + |
| 41 | +# path to the cache (.json) file containing the image layers |
| 42 | +genpolicy-cache-path = "./path/to/layers-cache.json" |
| 43 | + |
| 44 | +# path to the manifest (.json) file |
| 45 | +manifest = "./path/to/manifest.json" |
| 46 | + |
| 47 | +# set the default reference values used for attestation |
| 48 | +reference-values = "Metal-QEMU-SNP" |
| 49 | + |
| 50 | +# ... |
| 51 | +``` |
| 52 | + |
| 53 | +All configuration relevant to the CLI is nested under this section. |
| 54 | +Additional sections may be introduced as needed, but a single configuration file is used for the entire project. |
| 55 | + |
| 56 | +All fields are optional; if users specify a required field neither in the configuration file nor in their CLI arguments, |
| 57 | +the existing argument validation logic will inform the user in the same way it currently does. |
| 58 | + |
| 59 | +### Mapping CLI flags to configuration |
| 60 | + |
| 61 | +The configuration file mirrors the existing CLI surface: |
| 62 | +all flags, arguments, and options have corresponding fields in the configuration. |
| 63 | + |
| 64 | +Command-line arguments always take precedence over values loaded from the configuration file. |
| 65 | +This ensures that: |
| 66 | +- Users who don't use a configuration file experience no behavior changes. |
| 67 | +- Existing tooling and scripts continue to work unchanged. |
| 68 | +- Users may selectively override configuration values for one-off invocations. |
| 69 | + |
| 70 | +### Duplicate fields |
| 71 | + |
| 72 | +Contrast subcommands do share a subset of their arguments. |
| 73 | +For example, `generate`, `set`, `verify` and `recover` all require setting the `manifest` argument. |
| 74 | + |
| 75 | +We could adapt the structure of the configuration file to match this, for example: |
| 76 | + |
| 77 | +```toml |
| 78 | +[cli.generate] |
| 79 | +# path to the manifest (.json) file |
| 80 | +manifest = "./path/to/manifest.json" |
| 81 | +# ... |
| 82 | + |
| 83 | +[cli.set] |
| 84 | +# path to the manifest (.json) file |
| 85 | +manifest = "./path/to/manifest.json" |
| 86 | +# ... |
| 87 | + |
| 88 | +[cli.verify] |
| 89 | +# path to the manifest (.json) file |
| 90 | +manifest = "./path/to/manifest.json" |
| 91 | +# ... |
| 92 | + |
| 93 | +[cli.recover] |
| 94 | +# path to the manifest (.json) file |
| 95 | +manifest = "./path/to/manifest.json" |
| 96 | +# ... |
| 97 | +``` |
| 98 | + |
| 99 | +However, the need for this seems questionable: for all options shared between the commands, values aren't expected to differ. |
| 100 | +If a user does require overriding such a shared argument in a single invocation, they can always override the configuration file value in the CLI command. |
| 101 | +The procedure to obtain the relevant configuration options for a subcommand from the configuration file are shown below. |
| 102 | + |
| 103 | +### Internal representation |
| 104 | + |
| 105 | +Internally, the configuration is represented as a single, unified Go struct. |
| 106 | +This struct contains all configuration fields relevant to the application, and serves as the single source of truth for configuration options. |
| 107 | +More on this below. |
| 108 | + |
| 109 | +```go |
| 110 | +type Config struct { |
| 111 | + CLI struct { |
| 112 | + // shared |
| 113 | + LogLevel string `toml:"log-level" comment:"set logging level (debug, info, warn, error, or a number)"` |
| 114 | + WorkspaceDir string `toml:"workspace-dir" comment:"directory to write files to, if not set explicitly to another location"` |
| 115 | + |
| 116 | + ManifestPath string `toml:"manifest" comment:"path to manifest (.json) file"` |
| 117 | + Coordinator string `toml:"coordinator" comment:"endpoint the coordinator can be reached at"` |
| 118 | + PolicyPath string `toml:"policy-path" comment:"path to policy (.rego) file"` |
| 119 | + WorkloadOwnerKeyPath string `toml:"workload-owner-key" comment:"path to workload owner key (.pem) file"` |
| 120 | + // ... |
| 121 | + |
| 122 | + // generate-specific |
| 123 | + SettingsPath string `toml:"settings" comment:"path to settings (.json) file"` |
| 124 | + GenpolicyCachePath string `toml:"genpolicy-cache-path" comment:"path to cache for the cache (.json) file containing the image layers"` |
| 125 | + ReferenceValuesPlatform string `toml:"reference-values" comment:"set the default reference values used for attestation"` |
| 126 | + WorkloadOwnerKeys []string `toml:"add-workload-owner-keys" comment:"add a workload owner key from a PEM file to the manifest (set more than once to add multiple keys)"` |
| 127 | + // ... |
| 128 | + |
| 129 | + // set-specific |
| 130 | + // ... |
| 131 | + } |
| 132 | +} |
| 133 | +``` |
| 134 | + |
| 135 | +The struct makes use of the `toml` and `comment` annotations provided by [pelletier/go-toml](https://pkg.go.dev/github.com/pelletier/go-toml/v2#example-Marshal-Commented). |
| 136 | +A function `Default` is added to create a instance of the struct with default values set mirroring their current defaults in the `cobra.Command` subcommands: |
| 137 | + |
| 138 | +```go |
| 139 | +func Default() Config { |
| 140 | + return Config{ |
| 141 | + CLI: { |
| 142 | + // shared |
| 143 | + LogLevel: "warn", |
| 144 | + |
| 145 | + ManifestPath: "manifest.json", |
| 146 | + PolicyPath: "rules.rego", |
| 147 | + WorkloadOwnerKeyPath: "workload-owner.pem", |
| 148 | + // ... |
| 149 | + |
| 150 | + // generate-specific |
| 151 | + SettingsPath: "settings.json", |
| 152 | + GenpolicyCachePath: "layers-cache.json", |
| 153 | + WorkloadOwnerKeys: []string{"workload-owner.pem"}, |
| 154 | + // ... |
| 155 | + |
| 156 | + // set-specific |
| 157 | + // ... |
| 158 | + }, |
| 159 | + } |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +The `Default` function has two main purposes. |
| 164 | +First, by creating a `Config` object through it, then unmarshaling a TOML configuration into the resulting struct, analogous behavior to the current one (that is, some missing flags fall back to defaults) is achieved. |
| 165 | +Secondly, we can trivially marshal the default `Config` object to obtain a commented, default-configured TOML file which users can use as a starting point for their configuration. |
| 166 | + |
| 167 | +### `cobra.Command` derivations |
| 168 | + |
| 169 | +The `Config` struct as depicted above uses the `toml` and `comment` annotations. |
| 170 | +An optional `short:"<char>"` annotation is added, that is, |
| 171 | + |
| 172 | +```go |
| 173 | +type Config struct { |
| 174 | + CLI struct { |
| 175 | + // ... |
| 176 | + PolicyPath string `toml:"policy-path" short:"p" comment:"path to policy (.rego) file"` |
| 177 | + // ... |
| 178 | + } |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +and so forth. |
| 183 | +Together with the `Default` function, this provides all necessary information to derive the Cobra (sub-)commands. |
| 184 | +The functions currently used to create the `cobra.Command` structs for (sub-)commands get simplified as shown below for `NewGenerateCommand`. |
| 185 | + |
| 186 | +```go |
| 187 | +func NewGenerateCmd(cfg Config) *cobra.Command { |
| 188 | + cmd := &cobra.Command{ |
| 189 | + Use: "generate [flags] paths...", |
| 190 | + Short: "generate policies and inject into Kubernetes resources", |
| 191 | + Long: `Generate policies and inject into the given Kubernetes resources. [...]`, |
| 192 | + RunE: withTelemetry(runGenerate), |
| 193 | + } |
| 194 | + // ... |
| 195 | + |
| 196 | + AddArgs(cfg, cmd, []string{ |
| 197 | + "policy", |
| 198 | + "settings", |
| 199 | + "genpolicy-cache-path", |
| 200 | + "manifest", |
| 201 | + |
| 202 | + "workload-owner-key", |
| 203 | + "disable-updates", |
| 204 | + // ... |
| 205 | + }) |
| 206 | + |
| 207 | + cmd.MarkFlagsMutuallyExclusive("add-workload-owner-key", "disable-updates") |
| 208 | + return cmd |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +Here, `AddArgs` takes the provided (default) `Config` and the `cobra.Command`, as well as a slice of argument names matching the ones used in the `toml` annotations. |
| 213 | +It then adds a `cobra.Command` argument for each one of those names, using the metadata from the `toml` annotations for short names and help texts. |
| 214 | + |
| 215 | +Validation of the provided arguments continues to work just as it currently does (for example `parseGenerateFlags`), with the only difference being that this also runs on the new `Config` struct. |
| 216 | + |
| 217 | +### Configuration loading and precedence |
| 218 | + |
| 219 | +The `contrast` root command receives a new optional, persistent flag `--config`. |
| 220 | +Upon invocation of any CLI (sub-)command, the following steps are performed: |
| 221 | +- In the `OnInitialize` function of the root command: |
| 222 | + - Create a `Config` object via `Default()` |
| 223 | + - If the `--config` flag was set, load the specified configuration file. |
| 224 | + - If the file is specified, but missing or can't be parsed, exit with error. |
| 225 | + - Unmarshal the config file into the default configuration. |
| 226 | + - Pass the config struct to the subcommands. |
| 227 | +- In each subcommand: |
| 228 | + - For all flags set in the CLI invocation, override the corresponding `Config` struct field. |
| 229 | + - Apply the current validation logic. |
| 230 | + |
| 231 | +### Parsing behavior |
| 232 | + |
| 233 | +When parsing the configuration file, unknown fields intentionally result in an error to prevent typos or use of deprecated fields. |
| 234 | +Missing fields don't result in errors, that is, all fields are optional in the configuration file. |
| 235 | + |
| 236 | +### Versioning |
| 237 | + |
| 238 | +A version field may be included in the configuration file to allow explicit handling of breaking changes: |
| 239 | + |
| 240 | +```toml |
| 241 | +version = 1.17 |
| 242 | +``` |
| 243 | + |
| 244 | +This would allow us to perform compatibility checks when loading older configurations. |
| 245 | +However, versioning is considered optional at this stage and may be introduced later if required. |
| 246 | + |
| 247 | +### Backward and Forward Compatibility |
| 248 | + |
| 249 | +Backward compatibility is ensured by: |
| 250 | +- Making the configuration file entirely optional. |
| 251 | +- Giving precedence to CLI flags over configuration values. |
| 252 | +- Preserving the existing CLI interface. |
| 253 | + |
| 254 | +## Further applications |
| 255 | + |
| 256 | +In addition to using the configuration file for simplifying CLI uses, other applications could also be considered. |
| 257 | +The structure of the configuration file and the `Config` struct, that is, a nested `CLI` struct or table inside it, |
| 258 | +serves the purposes of compartmentalizing these different applications. |
| 259 | + |
| 260 | +One such additional application is sketched out below. |
| 261 | + |
| 262 | +### Reference values |
| 263 | + |
| 264 | +Currently, a `--reference-values` argument needs to be passed to the CLI in (most) invocations of `generate`. |
| 265 | +Afterward, users need to manually fill in the actual reference values for the specified platforms in the manifest. |
| 266 | + |
| 267 | +Allowing the users to instead set these values directly in the configuration file provides a dedicated place to store these values across manifest lifetimes, |
| 268 | +and to populate the manifest directly from this file, without additional user involvement. |
| 269 | +```toml |
| 270 | +[[reference_values]] |
| 271 | +platform = "Metal-QEMU-SNP" |
| 272 | +patch = ''' |
| 273 | +[ |
| 274 | + ... |
| 275 | +] |
| 276 | +''' |
| 277 | +# ... |
| 278 | +``` |
| 279 | + |
| 280 | +The actual values here are JSON-patches, in keeping with how we handle our own reference value patches. |
| 281 | + |
| 282 | +Again, these sections should be completely optional. |
| 283 | +Either passing `--reference-values` to the CLI *or* setting `cli.reference_values` in the CLI section of the configuration file should preclude the use of these values. |
| 284 | + |
| 285 | +## Alternatives considered |
| 286 | + |
| 287 | +### Using Viper |
| 288 | + |
| 289 | +The [Viper](https://github.com/spf13/viper) library was considered due to its easy integration with Cobra and the built-in support for config files. |
| 290 | +However, Viper is geared more towards interacting with a configuration file from within an application, that is, loading, editing and saving a config file. |
| 291 | +Working with a single configuration struct and deriving the subcommands from this configuration also doesn't appear simpler than implementing the above suggestions manually. |
0 commit comments