2026-10-11 01:06:21 +00:00
|
|
|
package ckconfig
|
2026-10-07 13:50:48 +00:00
|
|
|
|
|
|
|
|
import (
|
2026-11-07 04:52:58 +00:00
|
|
|
"errors"
|
2026-10-07 13:50:48 +00:00
|
|
|
"fmt"
|
2026-11-07 04:52:58 +00:00
|
|
|
"slices"
|
2026-11-07 03:57:10 +00:00
|
|
|
"text/template"
|
2026-10-07 13:50:48 +00:00
|
|
|
|
2026-11-04 03:52:46 +00:00
|
|
|
commandkit "go.maximumdirect.net/commandkit"
|
2026-11-07 04:52:58 +00:00
|
|
|
configkit "go.maximumdirect.net/configkit"
|
2026-10-07 13:50:48 +00:00
|
|
|
)
|
|
|
|
|
|
2026-11-07 03:57:10 +00:00
|
|
|
type validationReport struct {
|
|
|
|
|
Scopes []validationScope `json:"scopes"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type validationScope struct {
|
2026-11-07 04:52:58 +00:00
|
|
|
Scope string `json:"scope"`
|
|
|
|
|
Outcome string `json:"outcome"`
|
|
|
|
|
Details *validationDetails `json:"details,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type validationDetails struct {
|
|
|
|
|
State string `json:"state"`
|
|
|
|
|
Findings []validationFinding `json:"findings"`
|
|
|
|
|
Sources []validationSource `json:"sources"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type validationFinding struct {
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
Severity string `json:"severity"`
|
|
|
|
|
Path string `json:"path"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Primary *locationDTO `json:"primary"`
|
|
|
|
|
Related []locationDTO `json:"related"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type validationSource struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
Path string `json:"path"`
|
2026-11-07 03:57:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var validationTextTemplate = template.Must(commandkit.ParseTextTemplate("validation", "{{range .Scopes}}{{.Scope}}: {{.Outcome}}\n{{end}}", nil))
|
2026-11-07 04:52:58 +00:00
|
|
|
var validationDetailsTemplate = template.Must(commandkit.ParseTextTemplate("validation-details", `{{range .Scopes}}{{.Scope}}: {{.Outcome}} ({{.Details.State}})
|
|
|
|
|
{{range .Details.Findings}} {{.Severity}} {{.Code}} {{printf "%q" .Path}}: {{.Message}}
|
|
|
|
|
{{if .Primary}} {{.Primary.SourceID}}:{{.Primary.Line}}:{{.Primary.Column}}
|
|
|
|
|
{{end}}{{range .Related}} {{.SourceID}}:{{.Line}}:{{.Column}}
|
|
|
|
|
{{end}}{{end}}{{range .Details.Sources}} source {{printf "%q" .ID}}: {{.Label}} {{.Path}}
|
|
|
|
|
{{end}}{{end}}`, nil))
|
|
|
|
|
|
|
|
|
|
func parseValidationSeverity(raw string) (configkit.Severity, error) {
|
|
|
|
|
switch configkit.Severity(raw) {
|
|
|
|
|
case configkit.SeverityError, configkit.SeverityWarning:
|
|
|
|
|
return configkit.Severity(raw), nil
|
|
|
|
|
default:
|
|
|
|
|
return "", fmt.Errorf("unknown validation severity %q", raw)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-11-07 03:57:10 +00:00
|
|
|
|
2026-10-11 01:39:23 +00:00
|
|
|
func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
2026-10-26 03:16:02 +00:00
|
|
|
reporter := invocation.Reporter()
|
2026-11-07 03:57:10 +00:00
|
|
|
report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
|
|
|
|
|
outcome := commandkit.Success()
|
2026-10-11 01:39:23 +00:00
|
|
|
for _, selected := range plan.scopes {
|
2026-10-07 16:47:28 +00:00
|
|
|
if err := invocation.Context.Err(); err != nil {
|
2026-11-07 03:57:10 +00:00
|
|
|
outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
|
|
|
|
|
break
|
2026-10-07 16:47:28 +00:00
|
|
|
}
|
2026-11-07 03:57:10 +00:00
|
|
|
name := selected.scope.scope.Name
|
2026-10-11 01:39:23 +00:00
|
|
|
if selected.scope.scope.Acquire == nil {
|
2026-11-07 04:52:58 +00:00
|
|
|
entry := validationScope{Scope: name, Outcome: "failed"}
|
|
|
|
|
if plan.details {
|
|
|
|
|
entry.Details = projectValidationDetails(View{}, plan.severities, plan.codes)
|
|
|
|
|
}
|
|
|
|
|
report.Scopes = append(report.Scopes, entry)
|
2026-11-07 03:57:10 +00:00
|
|
|
outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
|
|
|
|
|
break
|
2026-10-11 01:39:23 +00:00
|
|
|
}
|
2026-11-07 04:39:34 +00:00
|
|
|
acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: selected.selection}, selected.input, state)
|
2026-11-07 04:52:58 +00:00
|
|
|
queueConfigDiagnostics(reporter, acquired.Diagnostics)
|
|
|
|
|
if !plan.details && acquired.Value.Findings != nil {
|
|
|
|
|
queueConfigDiagnostics(reporter, acquired.Value.Findings.Messages)
|
|
|
|
|
}
|
|
|
|
|
entry := validationScope{Scope: name, Outcome: "valid"}
|
|
|
|
|
if plan.details {
|
|
|
|
|
entry.Details = projectValidationDetails(acquired.Value, plan.severities, plan.codes)
|
|
|
|
|
}
|
|
|
|
|
if canceled := invocation.Context.Err(); canceled != nil {
|
|
|
|
|
entry.Outcome = "canceled"
|
|
|
|
|
if err != nil && !errors.Is(err, canceled) {
|
|
|
|
|
err = errors.Join(err, canceled)
|
|
|
|
|
} else if err == nil {
|
|
|
|
|
err = canceled
|
2026-11-07 03:57:10 +00:00
|
|
|
}
|
2026-11-07 04:52:58 +00:00
|
|
|
} else if err != nil {
|
|
|
|
|
entry.Outcome = "failed"
|
2026-10-07 13:50:48 +00:00
|
|
|
}
|
2026-11-07 04:52:58 +00:00
|
|
|
report.Scopes = append(report.Scopes, entry)
|
|
|
|
|
if err != nil {
|
|
|
|
|
message := fmt.Sprintf("Unable to validate configuration scope %q.", name)
|
|
|
|
|
if entry.Outcome == "canceled" {
|
|
|
|
|
message = "Configuration validation was canceled."
|
|
|
|
|
}
|
|
|
|
|
outcome = configFailure(message, acquired.Explanation, err)
|
2026-11-07 03:57:10 +00:00
|
|
|
break
|
2026-10-07 13:50:48 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-11-07 04:52:58 +00:00
|
|
|
text := validationTextTemplate
|
|
|
|
|
if plan.details {
|
|
|
|
|
text = validationDetailsTemplate
|
2026-10-07 13:50:48 +00:00
|
|
|
}
|
2026-11-07 04:52:58 +00:00
|
|
|
_ = reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
|
2026-11-07 03:57:10 +00:00
|
|
|
return outcome
|
2026-10-07 13:50:48 +00:00
|
|
|
}
|
2026-11-07 04:52:58 +00:00
|
|
|
|
|
|
|
|
func projectValidationDetails(view View, severities []configkit.Severity, codes []string) *validationDetails {
|
|
|
|
|
details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}}
|
|
|
|
|
if view.Findings == nil {
|
|
|
|
|
return details
|
|
|
|
|
}
|
|
|
|
|
if len(view.Findings.Records) == 0 {
|
|
|
|
|
details.State = "empty"
|
|
|
|
|
return details
|
|
|
|
|
}
|
|
|
|
|
details.State = "filtered"
|
|
|
|
|
references := make(map[string]bool)
|
|
|
|
|
for _, record := range view.Findings.Records {
|
|
|
|
|
if len(severities) > 0 && !slices.Contains(severities, record.Severity) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if len(codes) > 0 && !slices.Contains(codes, string(record.Code)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}
|
|
|
|
|
if record.Primary != nil {
|
|
|
|
|
finding.Primary = &locationDTO{SourceID: record.Primary.SourceID, Line: record.Primary.Line, Column: record.Primary.Column}
|
|
|
|
|
references[record.Primary.SourceID] = true
|
|
|
|
|
}
|
|
|
|
|
for _, location := range record.Related {
|
|
|
|
|
references[location.SourceID] = true
|
|
|
|
|
}
|
|
|
|
|
details.Findings = append(details.Findings, finding)
|
|
|
|
|
}
|
|
|
|
|
if len(details.Findings) > 0 {
|
|
|
|
|
details.State = "present"
|
|
|
|
|
}
|
|
|
|
|
if view.Sources != nil {
|
|
|
|
|
for _, source := range view.Sources.Sources() {
|
|
|
|
|
if references[source.ID] {
|
|
|
|
|
details.Sources = append(details.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return details
|
|
|
|
|
}
|