Files
commandkit/integrations/ckconfig/explain.go

244 lines
8.8 KiB
Go
Raw Permalink Normal View History

package ckconfig
2026-10-07 14:17:46 +00:00
import (
"bytes"
"fmt"
2026-11-07 04:44:01 +00:00
"strings"
"text/template"
2026-10-07 14:17:46 +00:00
commandkit "go.maximumdirect.net/commandkit"
2026-11-04 14:58:20 +00:00
configkit "go.maximumdirect.net/configkit"
2026-10-07 14:17:46 +00:00
)
type explanationReport struct {
Scope string `json:"scope"`
Path string `json:"path"`
Effective snapshotDTO `json:"effective"`
Origin explanationOriginDTO `json:"origin"`
History []explanationChangeDTO `json:"history"`
SecretReference *secretReferenceDTO `json:"secret_reference"`
2026-11-07 04:44:01 +00:00
Metadata *explanationMetadata `json:"metadata,omitempty"`
2026-10-07 14:17:46 +00:00
}
2026-11-07 04:44:01 +00:00
type explanationMetadata struct {
Explicit *bool `json:"explicit,omitempty"`
Null *bool `json:"null,omitempty"`
History []metadataHistoryRecord `json:"history,omitempty"`
}
type metadataHistoryRecord struct {
Path string `json:"path"`
Previous []explanationOriginDTO `json:"previous,omitempty"`
}
type pathDiscoveryReport struct {
Scope string `json:"scope"`
Paths []pathDiscoveryEntry `json:"paths"`
}
type pathDiscoveryEntry struct {
Path string `json:"path"`
Present bool `json:"present"`
Metadata *pathMetadata `json:"metadata,omitempty"`
}
type pathMetadata struct {
Explicit bool `json:"explicit"`
}
var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil))
2026-10-07 14:17:46 +00:00
type snapshotDTO struct {
Present bool `json:"present"`
Redacted bool `json:"redacted"`
YAML string `json:"yaml"`
2026-10-07 14:17:46 +00:00
}
type explanationOriginDTO struct {
Layer string `json:"layer"`
Locations []locationDTO `json:"locations"`
Operation string `json:"operation"`
Inheritance []inheritanceStepDTO `json:"inheritance"`
2026-10-07 14:17:46 +00:00
}
type inheritanceStepDTO struct {
From string `json:"from"`
To string `json:"to"`
Reference string `json:"reference"`
Locations []locationDTO `json:"locations"`
2026-10-07 14:17:46 +00:00
}
type explanationChangeDTO struct {
Before snapshotDTO `json:"before"`
After snapshotDTO `json:"after"`
Origin explanationOriginDTO `json:"origin"`
Reasons []string `json:"reasons"`
Dependencies []string `json:"dependencies"`
2026-10-07 14:17:46 +00:00
}
type secretReferenceDTO struct {
Name string `json:"name"`
2026-10-07 14:17:46 +00:00
}
2026-10-11 01:39:23 +00:00
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
selected := plan.scopes[0]
name := selected.scope.scope.Name
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", commandkit.Explanation{}, err)
2026-10-07 14:17:46 +00:00
}
2026-10-11 01:39:23 +00:00
if selected.scope.scope.Acquire == nil {
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), commandkit.Explanation{}, cause)
2026-10-07 14:17:46 +00:00
}
2026-10-11 01:39:23 +00:00
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Explain,
2026-11-07 04:39:34 +00:00
Selection: selected.selection,
2026-10-11 01:39:23 +00:00
}, selected.input, state)
queueConfigAcquisition(reporter, acquired)
2026-10-07 14:17:46 +00:00
if acquireErr != nil {
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr)
2026-10-07 14:17:46 +00:00
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
2026-11-07 04:44:01 +00:00
if plan.list {
if acquired.Value.Metadata == nil {
return configFailure("Configuration scope did not provide path metadata.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path discovery without metadata", name))
}
report := pathDiscoveryReport{Scope: name, Paths: []pathDiscoveryEntry{}}
for _, path := range acquired.Value.Metadata.HistoricalPaths() {
if plan.under != nil && !pathWithin(path, *plan.under) {
continue
}
info, present := acquired.Value.Metadata.Lookup(path)
if !present && (!plan.historical || plan.explicit.Set) {
continue
}
if present && plan.explicit.Set && info.Explicit != plan.explicit.Value {
continue
}
entry := pathDiscoveryEntry{Path: path.String(), Present: present}
if present {
entry.Metadata = &pathMetadata{Explicit: info.Explicit}
}
report.Paths = append(report.Paths, entry)
}
_ = reporter.Report(commandkit.Report{Value: report, TextTemplate: pathDiscoveryTemplate})
return commandkit.Success()
}
2026-10-07 14:17:46 +00:00
if acquired.Value.Explanation == nil {
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
2026-10-07 14:17:46 +00:00
}
2026-10-11 01:39:23 +00:00
explanation, found := acquired.Value.Explanation.Explain(plan.path)
2026-10-07 14:17:46 +00:00
if !found {
cause := fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name)
return configFailure("The requested configuration path is not available.", acquired.Explanation, cause)
2026-10-07 14:17:46 +00:00
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
2026-11-07 04:44:01 +00:00
report := makeExplanationReport(name, explanation)
if acquired.Value.Metadata != nil {
metadata := &explanationMetadata{}
if info, ok := acquired.Value.Metadata.Lookup(plan.path); ok {
metadata.Explicit, metadata.Null = &info.Explicit, &info.Null
}
for _, change := range acquired.Value.Metadata.History(plan.path) {
entry := metadataHistoryRecord{Path: change.Path.String()}
for _, previous := range change.Previous {
entry.Previous = append(entry.Previous, originReport(previous))
}
metadata.History = append(metadata.History, entry)
}
if metadata.Explicit != nil || len(metadata.History) > 0 {
report.Metadata = metadata
}
}
text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{
2026-11-07 04:44:01 +00:00
"render": func(report explanationReport) (string, error) {
var output bytes.Buffer
err := acquired.Value.Explanation.WriteExplanation(&output, plan.path)
2026-11-07 04:44:01 +00:00
if err == nil && report.Metadata != nil && report.Metadata.Explicit != nil {
_, err = fmt.Fprintf(&output, "Explicit: %t\nNull: %t\n", *report.Metadata.Explicit, *report.Metadata.Null)
}
return output.String(), err
},
}))
2026-11-07 04:44:01 +00:00
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
if renderErr != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
2026-10-07 14:17:46 +00:00
}
return commandkit.Success()
2026-10-07 14:17:46 +00:00
}
2026-11-07 04:44:01 +00:00
func pathWithin(path, parent configkit.Path) bool {
tokens := func(value string) []string {
if value == "" {
return nil
}
return strings.Split(strings.TrimPrefix(value, "/"), "/")
}
child, prefix := tokens(path.String()), tokens(parent.String())
if len(prefix) > len(child) {
return false
}
for index := range prefix {
if prefix[index] != child[index] {
return false
}
}
return true
}
2026-10-07 14:17:46 +00:00
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
report := explanationReport{
Scope: scope,
Path: explanation.Path.String(),
Effective: snapshotReport(explanation.Effective),
Origin: originReport(explanation.Origin),
History: make([]explanationChangeDTO, 0, len(explanation.History)),
}
for _, change := range explanation.History {
entry := explanationChangeDTO{
Before: snapshotReport(change.Before),
After: snapshotReport(change.After),
Origin: originReport(change.Origin),
Reasons: append([]string{}, change.Reasons...),
Dependencies: make([]string, 0, len(change.Dependencies)),
}
for _, dependency := range change.Dependencies {
entry.Dependencies = append(entry.Dependencies, dependency.String())
}
report.History = append(report.History, entry)
}
if explanation.SecretReference != nil {
report.SecretReference = &secretReferenceDTO{Name: explanation.SecretReference.Name}
}
return report
}
func snapshotReport(snapshot configkit.ValueSnapshot) snapshotDTO {
return snapshotDTO{Present: snapshot.Present, Redacted: snapshot.Redacted, YAML: snapshot.YAML}
}
func originReport(origin configkit.Origin) explanationOriginDTO {
report := explanationOriginDTO{
Layer: string(origin.Layer),
Locations: locationReports(origin.Locations),
Operation: string(origin.Operation),
Inheritance: make([]inheritanceStepDTO, 0, len(origin.Inheritance)),
}
for _, step := range origin.Inheritance {
report.Inheritance = append(report.Inheritance, inheritanceStepDTO{
From: step.From.String(),
To: step.To.String(),
Reference: step.Reference.String(),
Locations: locationReports(step.Locations),
})
}
return report
}