Files
distributor/internal/config/validate.go

236 lines
8.9 KiB
Go

package config
import (
"fmt"
"net/url"
"regexp"
"strings"
)
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
type ValidationErrors []string
func (e ValidationErrors) Error() string {
if len(e) == 1 {
return e[0]
}
return strings.Join(e, "; ")
}
func Validate(cfg Config) error {
var errs ValidationErrors
if len(cfg.Pipelines) == 0 {
errs = append(errs, "pipelines is required")
}
pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines))
for pipelineIndex, pipeline := range cfg.Pipelines {
pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex)
if pipeline.ID == "" {
errs = append(errs, pipelineContext+".id is required")
} else if !idPattern.MatchString(pipeline.ID) {
errs = append(errs, pipelineContext+".id must be a slug-like identifier")
} else if _, exists := pipelineIDs[pipeline.ID]; exists {
errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated")
} else {
pipelineIDs[pipeline.ID] = struct{}{}
}
errs = validateSourceBackend(errs, pipelineContext+".source", pipeline.Source)
errs = validateValidationPolicy(errs, pipelineContext+".validation", pipeline.Validation)
if len(pipeline.Destinations) == 0 {
errs = append(errs, pipelineContext+".destinations is required")
}
destinationIDs := make(map[string]struct{}, len(pipeline.Destinations))
for destinationIndex, destination := range pipeline.Destinations {
destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex)
if destination.ID == "" {
errs = append(errs, destinationContext+".id is required")
} else if !idPattern.MatchString(destination.ID) {
errs = append(errs, destinationContext+".id must be a slug-like identifier")
} else if _, exists := destinationIDs[destination.ID]; exists {
errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID)
} else {
destinationIDs[destination.ID] = struct{}{}
}
errs = validateDestinationBackend(errs, destinationContext, destination)
errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform)
errs = validatePathMapping(errs, destinationContext+".path_mapping", destination.PathMap)
errs = validateLinks(errs, destinationContext+".links", destination.Links)
errs = validateTransferPolicy(errs, destinationContext+".transfer", destination.Transfer)
}
}
if len(errs) > 0 {
return errs
}
return nil
}
func validateSourceBackend(errs ValidationErrors, context string, backend Backend) ValidationErrors {
return validateBackend(errs, context, backend.Backend, backend.Host, backend.Port, backend.Path, backend.Endpoint, backend.Bucket, backend.Prefix, backend.SSH.HostKeyPolicy, backend.Creds)
}
func validateDestinationBackend(errs ValidationErrors, context string, destination Destination) ValidationErrors {
return validateBackend(errs, context, destination.Backend, destination.Host, destination.Port, destination.Path, destination.Endpoint, destination.Bucket, destination.Prefix, destination.SSH.HostKeyPolicy, destination.Creds)
}
func validateBackend(errs ValidationErrors, context, backend, host string, port int, path, endpoint, bucket, prefix string, hostKeyPolicy HostKeyPolicy, creds Credentials) ValidationErrors {
switch backend {
case "":
errs = append(errs, context+".backend is required")
case BackendLocal:
if path == "" {
errs = append(errs, context+".path is required for local backend")
}
case BackendSSH:
if host == "" {
errs = append(errs, context+".host is required for ssh backend")
}
if path == "" {
errs = append(errs, context+".path is required for ssh backend")
}
if port < 0 || port > 65535 {
errs = append(errs, context+".port must be between 1 and 65535")
}
if port == 0 {
errs = append(errs, context+".port is required for ssh backend after defaults are applied")
}
if hostKeyPolicy != "" {
if _, ok := NormalizeHostKeyPolicy(string(hostKeyPolicy)); !ok {
errs = append(errs, context+".host_key_policy must be strict, true, accept-new, off, or false")
}
}
case BackendS3:
if endpoint == "" {
errs = append(errs, context+".endpoint is required for s3 backend")
}
if bucket == "" {
errs = append(errs, context+".bucket is required for s3 backend")
}
if err := ValidateS3Prefix(prefix); err != nil {
errs = append(errs, context+".prefix must be a clean relative slash-separated path")
}
if (creds.AccessKeyIDEnv == "") != (creds.SecretAccessKeyEnv == "") {
errs = append(errs, context+".credentials.access_key_id_env and credentials.secret_access_key_env must be configured together")
}
default:
errs = append(errs, context+".backend "+backend+" is unsupported")
}
return errs
}
func validateValidationPolicy(errs ValidationErrors, context string, policy ValidationPolicy) ValidationErrors {
if policy.OnDigestMismatch != ValidationActionFail {
errs = append(errs, context+".on_digest_mismatch must be "+ValidationActionFail)
}
return errs
}
func validatePublishTransformPolicy(errs ValidationErrors, context string, policy *PublishPolicy, transform Transform) ValidationErrors {
if policy == nil {
errs = append(errs, context+".publish is required")
return errs
}
if err := ValidatePublishTransformPolicy(*policy, transform); err != nil {
errs = append(errs, context+"."+err.Error())
}
return errs
}
func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform) error {
if !publish.Source && !publish.HTML {
return fmt.Errorf("publish must enable source or html")
}
if publish.HTML && transform.MarkdownToHTML == nil {
return fmt.Errorf("transform.markdown_to_html is required when publish.html is true")
}
if transform.MarkdownToHTML == nil {
return nil
}
mode := transform.MarkdownToHTML.Mode
if mode == "" {
mode = TransformModeSidecar
}
if mode != TransformModeSidecar && mode != TransformModeIndex {
return fmt.Errorf("transform.markdown_to_html.mode must be %s or %s", TransformModeSidecar, TransformModeIndex)
}
if transform.MarkdownToHTML.Input != "" && !transform.MarkdownToHTML.Enabled {
return fmt.Errorf("transform.markdown_to_html.input requires transform.markdown_to_html.enabled to be true")
}
if transform.MarkdownToHTML.Input != "" && mode != TransformModeIndex {
return fmt.Errorf("transform.markdown_to_html.input is only valid when mode is %s", TransformModeIndex)
}
if transform.MarkdownToHTML.Enabled && !publish.HTML {
return fmt.Errorf("transform.markdown_to_html.enabled requires publish.html to be true")
}
if publish.HTML && !transform.MarkdownToHTML.Enabled {
return fmt.Errorf("transform.markdown_to_html.enabled must be true when publish.html is true")
}
return nil
}
func validatePathMapping(errs ValidationErrors, context string, mapping PathMapping) ValidationErrors {
if mapping.Mode != PathMappingPreserveRelative && mapping.Mode != PathMappingFixed {
errs = append(errs, context+".mode must be "+PathMappingPreserveRelative+" or "+PathMappingFixed)
}
return errs
}
func validateLinks(errs ValidationErrors, context string, links *Links) ValidationErrors {
if links == nil {
return errs
}
if links.BaseURL == "" {
errs = append(errs, context+".base_url is required")
} else if err := validateLinkBaseURL(links.BaseURL); err != nil {
errs = append(errs, context+".base_url "+err.Error())
}
switch links.Primary {
case LinkPrimaryAuto, LinkPrimaryHTML, LinkPrimarySource:
default:
errs = append(errs, context+".primary must be "+LinkPrimaryAuto+", "+LinkPrimaryHTML+", or "+LinkPrimarySource)
}
return errs
}
func validateLinkBaseURL(value string) error {
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("must be a valid URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("must use http or https")
}
if parsed.Host == "" {
return fmt.Errorf("must include a host")
}
if parsed.RawQuery != "" {
return fmt.Errorf("must not include a query string")
}
if parsed.Fragment != "" {
return fmt.Errorf("must not include a fragment")
}
return nil
}
func validateTransferPolicy(errs ValidationErrors, context string, policy TransferPolicy) ValidationErrors {
if policy.OnDestinationSame != TransferActionSkip && policy.OnDestinationSame != TransferActionFail {
errs = append(errs, context+".on_destination_same must be skip or fail")
}
if policy.OnDestinationOlder != TransferActionReplace && policy.OnDestinationOlder != TransferActionFail {
errs = append(errs, context+".on_destination_older must be replace or fail")
}
if policy.OnDestinationNewer != TransferActionSkip && policy.OnDestinationNewer != TransferActionFail && policy.OnDestinationNewer != TransferActionReplace {
errs = append(errs, context+".on_destination_newer must be skip, replace, or fail")
}
if policy.OnConflict != TransferActionFail && policy.OnConflict != TransferActionReplace {
errs = append(errs, context+".on_conflict must be fail or replace")
}
return errs
}