diff --git a/internal/command/cliconfig/cliconfig.go b/internal/command/cliconfig/cliconfig.go index f018566179..34a939bf1c 100644 --- a/internal/command/cliconfig/cliconfig.go +++ b/internal/command/cliconfig/cliconfig.go @@ -66,11 +66,15 @@ type Config struct { // that validation at validation time rather than initial decode time. ProviderInstallation []*ProviderInstallation - // OCIDefaultCredentials represents any oci_default_credentials blocks - // in the configuration. Only one of these is allowed across the whole - // configuration, but we decode into a slice here so that we can handle - // that validation at validation time rather than initial decode time. - OCIDefaultCredentials []*OCIDefaultCredentials + // OCIDefaultCredentials and OCIRepositoryCredentials together represent + // the individual OCI-credentials-related blocks in the configuration. + // + // Only one OCIDefaultCredentials element is allowed, but we validate + // that after loading the configuration. Zero or more OCICredentials + // blocks are allowed, but they must each have a unique repository + // prefix. + OCIDefaultCredentials []*OCIDefaultCredentials + OCIRepositoryCredentials []*OCIRepositoryCredentials } // ConfigHost is the structure of the "host" nested block within the CLI @@ -193,6 +197,9 @@ func loadConfigFile(path string) (*Config, tfdiags.Diagnostics) { ociDefaultCredsBlocks, ociDefaultCredsDiags := decodeOCIDefaultCredentialsFromConfig(obj) diags = diags.Append(ociDefaultCredsDiags) result.OCIDefaultCredentials = ociDefaultCredsBlocks + ociCredsBlocks, ociCredsDiags := decodeOCICredentialsFromConfig(obj) + diags = diags.Append(ociCredsDiags) + result.OCIRepositoryCredentials = ociCredsBlocks // Replace all env vars for k, v := range result.Providers { diff --git a/internal/command/cliconfig/oci_credentials.go b/internal/command/cliconfig/oci_credentials.go index f9886c20a1..79d890aaec 100644 --- a/internal/command/cliconfig/oci_credentials.go +++ b/internal/command/cliconfig/oci_credentials.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/hcl" hclast "github.com/hashicorp/hcl/hcl/ast" + "github.com/opentofu/opentofu/internal/command/cliconfig/ociauthconfig" "github.com/opentofu/opentofu/internal/tfdiags" ) @@ -239,9 +240,215 @@ type OCIRepositoryCredentials struct { // authentication method. These are mutually-exclusive with Username // and Password. AccessToken, RefreshToken string + + // DockerCredentialsHelper is the name of a Docker-style credential helper program + // to use. + // + // Docker-style config only allows credential helpers to be configured at + // whole-registry-domain granularity, so for consistency we only allow this to be + // set when RepositoryPathPrefix isn't set. + DockerCredentialHelper string } -// TODO: Implement decodeOCICredentialsFromConfig, returning []*OCIRepositoryCredentials +// decodeOCICredentialsFromConfig uses the HCL AST API directly to decode +// "oci_credentials" blocks from the given file. +// +// The overall CLI configuration can contain zero or more blocks of this +// type. We require that each one describes a distinct OCI repository +// address prefix, but that constraint must be enforced by the caller of +// this function because it must be checked across all of the CLI +// configuration files together, rather than just one file at a time. +// +// This uses the HCL AST directly, rather than HCL's decoder, to continue +// our precedent of trying to constrain new features only to what could be +// supported compatibly in a hypothetical future HCL 2-based implementation +// of the CLI configuration language. +// +// Note that this function wants the top-level file object which might or +// might not contain oci_credentials blocks, not an oci_credentials block +// directly itself. +func decodeOCICredentialsFromConfig(hclFile *hclast.File) ([]*OCIRepositoryCredentials, tfdiags.Diagnostics) { + var ret []*OCIRepositoryCredentials + var diags tfdiags.Diagnostics + + root, ok := hclFile.Node.(*hclast.ObjectList) + if !ok { + // A HCL file that doesn't have an object list at its root is weird, but + // dealing with that is outside the scope of this function. + // (In practice both the native syntax and JSON parsers for HCL force + // the root to be an ObjectList, so we should not get here for any real file.) + return ret, diags + } + for _, block := range root.Items { + if block.Keys[0].Token.Value() != "oci_credentials" { + continue + } + + // HCL only tracks whether the input was JSON or native syntax inside + // individual tokens, so we'll use our block type token to decide + // and assume that the rest of the block must be written in the same + // syntax, because syntax is a whole-file idea. + const errInvalidSummary = "Invalid oci_credentials block" + isJSON := block.Keys[0].Token.JSON + if block.Assign.Line != 0 && !isJSON { + // Seems to be an attribute rather than a block + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf("The oci_credentials block at %s must not be introduced with an equals sign.", block.Pos()), + )) + continue + } + if len(block.Keys) != 2 && !isJSON { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf("The oci_credentials block at %s must have one label, giving an OCI repository address prefix.", block.Pos()), + )) + continue + } + body, ok := block.Val.(*hclast.ObjectType) + if !ok { + // We can't get in here with native HCL syntax because we + // already checked above that we're using block syntax, but + // if we're reading JSON then our value could potentially be + // anything. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf("The oci_credentials block at %s must be represented by a JSON object.", block.Pos()), + )) + continue + } + + result, blockDiags := decodeOCICredentialsBlockBody(block.Keys[1].Token.Text, body) + diags = diags.Append(blockDiags) + if result != nil { + ret = append(ret, result) + } + } + + return ret, diags +} + +func decodeOCICredentialsBlockBody(label string, body *hclast.ObjectType) (*OCIRepositoryCredentials, tfdiags.Diagnostics) { + const errInvalidSummary = "Invalid oci_credentials block" + var diags tfdiags.Diagnostics + + registryDomain, repositoryPath, labelErr := ociauthconfig.ParseRepositoryAddressPrefix(label) + if labelErr != nil { + diags = append(diags, tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf( + "The oci_credentials block at %s has an invalid block label: %s.", + body.Pos(), labelErr, + ), + )) + return nil, diags + } + + // Although decodeOCICredentialsFromConfig did some lower-level decoding + // to try to force HCL 2-compatible syntax, the _content_ of this block is all + // just relatively-simple arguments and so we can use HCL 1's decoder here. + type BodyContent struct { + // The following three groups of arguments are mutually-exclusive. + + // Basic-auth-style credentials, statically configured + Username *string `hcl:"username"` + Password *string `hcl:"password"` + + // OAuth style credentials + AccessToken *string `hcl:"access_token"` + RefreshToken *string `hcl:"refresh_token"` + + // Docuer-style credentials helper providing Basic-auth-style credentials + // indirectly through an external program + DockerCredentialsHelper *string `hcl:"docker_credentials_helper"` + } + var bodyContent BodyContent + err := hcl.DecodeObject(&bodyContent, body) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf("Invalid oci_credentials block at %s: %s.", body.Pos(), err), + )) + return nil, diags + } + + staticBasicAuth := bodyContent.Username != nil || bodyContent.Password != nil + oauth := bodyContent.AccessToken != nil || bodyContent.RefreshToken != nil + dockerCredHelper := bodyContent.DockerCredentialsHelper != nil + stylesConfigured := trueCount(staticBasicAuth, oauth, dockerCredHelper) + if stylesConfigured == 0 { + diags = append(diags, tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf( + "The oci_credentials block at %s must set either username+password, access_token+refresh_token, or docker_credentials_helper.", + body.Pos(), + ), + )) + return nil, diags + } + if stylesConfigured > 1 { + diags = append(diags, tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf( + "The oci_credentials block at %s must set only one group out of username+password, access_token+refresh_token, or docker_credentials_helper.", + body.Pos(), + ), + )) + return nil, diags + } + + ret := &OCIRepositoryCredentials{ + RegistryDomain: registryDomain, + RepositoryPathPrefix: repositoryPath, + } + switch { + case staticBasicAuth: + if bodyContent.Username == nil || bodyContent.Password == nil { + diags = append(diags, tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf( + "The oci_credentials block at %s must set both username and password together when using static credentials.", + body.Pos(), + ), + )) + return nil, diags + } + ret.Username = *bodyContent.Username + ret.Password = *bodyContent.Password + case oauth: + // FIXME: Is refresh_roken actually required? We could potentially allow setting + // only access_token and let the request just immediately fail if the token has expired. + if bodyContent.AccessToken == nil || bodyContent.RefreshToken == nil { + diags = append(diags, tfdiags.Sourceless( + tfdiags.Error, + errInvalidSummary, + fmt.Sprintf( + "The oci_credentials block at %s must set both access_token and refresh_token together when using OAuth-style credentials.", + body.Pos(), + ), + )) + return nil, diags + } + ret.AccessToken = *bodyContent.AccessToken + ret.RefreshToken = *bodyContent.RefreshToken + case dockerCredHelper: + ret.DockerCredentialHelper = *bodyContent.DockerCredentialsHelper + } + + // TODO: Further validation rules, like: + // - is the docker credentials helper specified using valid syntax? + // - if docker credentials helper is specified, is the repositoryPath empty? (Docker credential helpers are only for entire registry domains) + + return ret, diags +} func validDockerCredentialHelperName(n string) bool { switch { @@ -260,3 +467,13 @@ func validDockerCredentialHelperName(n string) bool { return true } } + +func trueCount(flags ...bool) int { + ret := 0 + for _, flag := range flags { + if flag { + ret++ + } + } + return ret +}