Add Certificates page and Traefik API frontend client

This commit is contained in:
backup 2026-09-02 12:42:52 -05:00
commit 7fc90524b5
40 changed files with 6585 additions and 359 deletions

View file

@ -1,11 +1,13 @@
package file
import (
"fmt"
"strings"
"github.com/pmezard/go-difflib/difflib"
)
// UnifiedDiff returns a unified diff between old and new content
// UnifiedDiff returns a proper unified diff between old and new content using LCS (Myers via difflib).
// It handles insertions, deletions, replacements, moved blocks, empty and new files.
func UnifiedDiff(filename, oldContent, newContent string) string {
if oldContent == newContent {
return ""
@ -13,72 +15,45 @@ func UnifiedDiff(filename, oldContent, newContent string) string {
oldLines := splitLines(oldContent)
newLines := splitLines(newContent)
// Simple line-based diff: collect added/removed
// Use LCS-style? For MVP use simple prefix diff
var b strings.Builder
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", filename, filename)
// Find common prefix/suffix for brevity
// Instead do full: show removed then added
oldSet := make(map[string]int)
for _, l := range oldLines {
oldSet[l]++
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(oldContent),
B: difflib.SplitLines(newContent),
FromFile: "a/" + filename,
ToFile: "b/" + filename,
Context: 3,
}
newSet := make(map[string]int)
for _, l := range newLines {
newSet[l]++
// difflib.SplitLines handles trailing newlines correctly, but we also keep oldLines/newLines for
// fallback when one side is empty to ensure diff is still produced.
if len(oldLines) == 0 && len(newLines) == 0 {
return ""
}
// Very simple: if old empty (new file), show all as +
if len(oldLines) == 0 || (len(oldLines) == 1 && oldLines[0] == "") {
for _, l := range newLines {
fmt.Fprintf(&b, "+%s\n", l)
}
return b.String()
}
if len(newLines) == 0 {
for _, l := range oldLines {
fmt.Fprintf(&b, "-%s\n", l)
}
return b.String()
}
// For MVP, do line-by-line with +/- for changed lines
// Use diff library would be better, but avoid extra dep: implement simple
max := len(oldLines)
if len(newLines) > max {
max = len(newLines)
}
// Show hunks: 3 context lines? Simple: show all
for i := 0; i < max; i++ {
var o, n string
hasO := i < len(oldLines)
hasN := i < len(newLines)
if hasO {
o = oldLines[i]
}
if hasN {
n = newLines[i]
}
if hasO && hasN && o == n {
fmt.Fprintf(&b, " %s\n", o)
} else {
if hasO {
fmt.Fprintf(&b, "-%s\n", o)
}
if hasN {
fmt.Fprintf(&b, "+%s\n", n)
text, _ := difflib.GetUnifiedDiffString(diff)
// difflib returns empty string when no diff; ensure header is present for new/deleted files
if text == "" {
// Fallback minimal diff for edge cases difflib might consider empty
var b strings.Builder
b.WriteString("--- a/" + filename + "\n+++ b/" + filename + "\n")
if len(oldLines) == 0 {
for _, l := range newLines {
b.WriteString("+" + l + "\n")
}
return b.String()
}
if len(newLines) == 0 {
for _, l := range oldLines {
b.WriteString("-" + l + "\n")
}
return b.String()
}
return ""
}
return b.String()
return text
}
func splitLines(s string) []string {
if s == "" {
return []string{}
}
// Normalize line endings
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.Split(s, "\n")
}

View file

@ -0,0 +1,110 @@
package file
import (
"strings"
"testing"
)
func TestUnifiedDiff_Insertion(t *testing.T) {
old := "a\nb\nc"
new := "a\nx\nb\nc"
diff := UnifiedDiff("app.yml", old, new)
if !strings.Contains(diff, "+x") {
t.Fatalf("expected insertion of x, got %q", diff)
}
if !strings.Contains(diff, " a") || !strings.Contains(diff, " b") {
t.Fatalf("expected context lines, got %q", diff)
}
}
func TestUnifiedDiff_Deletion(t *testing.T) {
old := "a\nb\nc\nd"
new := "a\nc\nd"
diff := UnifiedDiff("app.yml", old, new)
if !strings.Contains(diff, "-b") {
t.Fatalf("expected deletion of b, got %q", diff)
}
}
func TestUnifiedDiff_Replacement(t *testing.T) {
old := "http:\n routers:\n r1:\n rule: \"Host(`a.com`)\"\n service: s1"
new := "http:\n routers:\n r1:\n rule: \"Host(`b.com`)\"\n service: s1"
diff := UnifiedDiff("app.yml", old, new)
if !strings.Contains(diff, "-") || !strings.Contains(diff, "+") {
t.Fatalf("expected replacement diff, got %q", diff)
}
if !strings.Contains(diff, "a.com") || !strings.Contains(diff, "b.com") {
t.Fatalf("expected both old and new rule in diff, got %q", diff)
}
}
func TestUnifiedDiff_MovedBlock(t *testing.T) {
old := "line1\nline2\nline3\nline4\nline5"
new := "line1\nline4\nline5\nline2\nline3"
diff := UnifiedDiff("app.yml", old, new)
// moved block should appear as deletion and insertion (difflib shows moved 4/5)
if !strings.Contains(diff, "-line4") || !strings.Contains(diff, "-line5") {
t.Fatalf("expected moved lines as deletion (4/5), got %q", diff)
}
if !strings.Contains(diff, "+line4") || !strings.Contains(diff, "+line5") {
t.Fatalf("expected moved lines as insertion (4/5), got %q", diff)
}
// also ensure diff is not empty and contains context
if !strings.Contains(diff, "line1") {
t.Fatalf("expected context line1, got %q", diff)
}
}
func TestUnifiedDiff_EmptyFiles(t *testing.T) {
diff := UnifiedDiff("app.yml", "", "")
if diff != "" {
t.Fatalf("empty both should be empty diff, got %q", diff)
}
}
func TestUnifiedDiff_NewFile(t *testing.T) {
old := ""
new := "http:\n routers:\n r:\n rule: \"Host(`x.com`)\""
diff := UnifiedDiff("new.yml", old, new)
if !strings.Contains(diff, "+http:") {
t.Fatalf("new file should show additions, got %q", diff)
}
if !strings.Contains(diff, "--- a/new.yml") || !strings.Contains(diff, "+++ b/new.yml") {
t.Fatalf("missing header, got %q", diff)
}
}
func TestUnifiedDiff_DeletedFile(t *testing.T) {
old := "http:\n routers:\n r:\n rule: \"Host(`x.com`)\""
new := ""
diff := UnifiedDiff("old.yml", old, new)
if !strings.Contains(diff, "-http:") {
t.Fatalf("deleted file should show deletions, got %q", diff)
}
}
func TestUnifiedDiff_RollbackDiff(t *testing.T) {
v1 := "http:\n routers:\n r1:\n rule: \"Host(`a.com`)\"\n service: s1"
v2 := "http:\n routers:\n r1:\n rule: \"Host(`b.com`)\"\n service: s1"
// Simulate apply v1 -> v2, then rollback v2 -> v1 diff should be reverse
diffForward := UnifiedDiff("app.yml", v1, v2)
diffRollback := UnifiedDiff("app.yml", v2, v1)
if diffForward == "" || diffRollback == "" {
t.Fatalf("both diffs should be non-empty")
}
if !strings.Contains(diffForward, "-") || !strings.Contains(diffRollback, "-") {
t.Fatalf("expected diffs to contain changes")
}
// Rollback diff should contain inverse
if !strings.Contains(diffRollback, "a.com") || !strings.Contains(diffRollback, "b.com") {
t.Fatalf("rollback diff missing expected, got %q", diffRollback)
}
}
func TestUnifiedDiff_Identical(t *testing.T) {
content := "http:\n routers:\n r: {rule: \"Host(`a.com`)\", service: s}"
diff := UnifiedDiff("app.yml", content, content)
if diff != "" {
t.Fatalf("identical should be empty, got %q", diff)
}
}

View file

@ -21,10 +21,15 @@ func (e ValidationError) Error() string {
return e.Message
}
// ValidateContent checks YAML content before write.
// ValidateContent performs syntax and structural validation of Traefik dynamic file-provider content.
// NOTE: This is NOT complete Traefik schema validation — it checks YAML syntax, top-level keys,
// and structural invariants (e.g., router requires rule+service, service requires loadBalancer/weighted,
// loadBalancer servers require url). Full Traefik CRD/schema validation (e.g., router rule grammar,
// middleware option types, TLS option values, unknown deeply-nested fields) is not exhaustive and
// should be considered syntax+structural validation. Traefik itself will still reject semantically
// invalid configs on reload; the GUI surfaces unified diff and Traefik status via /api/traefik/*.
// Rejects empty, dangerous, or structurally invalid configs.
// Allows only dynamic config top-level keys: http, tcp, udp, tls.
// Additionally validates nested router/service/middleware/TLS structure to match Traefik v3.7 dynamic schema.
func ValidateContent(filename, content string) []ValidationError {
var errs []ValidationError

View file

@ -0,0 +1,198 @@
package file
import "testing"
func TestValidateContent_InvalidRouterRule(t *testing.T) {
// missing rule
yaml := `http:
routers:
bad-router:
service: svc1
services:
svc1:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
`
errs := ValidateContent("app.yml", yaml)
if len(errs) == 0 {
t.Fatalf("expected error for missing rule")
}
found := false
for _, e := range errs {
if containsStr(e.Message, "rule") {
found = true
}
}
if !found {
t.Fatalf("expected rule error, got %v", errs)
}
}
func TestValidateContent_InvalidService(t *testing.T) {
yaml := `http:
routers:
r1:
rule: "Host(` + "`a.com`" + `)"
service: svc1
services:
svc1: {}
`
errs := ValidateContent("app.yml", yaml)
found := false
for _, e := range errs {
if containsStr(e.Message, "loadBalancer") {
found = true
}
}
if !found {
t.Fatalf("expected service definition error, got %v", errs)
}
}
func TestValidateContent_InvalidMiddleware(t *testing.T) {
yaml := `http:
routers:
r1:
rule: "Host(` + "`a.com`" + `)"
service: s1
services:
s1:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
middlewares:
m1: "not-a-mapping"
`
errs := ValidateContent("app.yml", yaml)
found := false
for _, e := range errs {
if containsStr(e.Message, "middlewares") {
found = true
}
}
if !found {
t.Fatalf("expected middleware error, got %v", errs)
}
}
func TestValidateContent_TCPInvalid(t *testing.T) {
yaml := `tcp:
routers:
tr1:
service: svc1
`
errs := ValidateContent("app.yml", yaml)
found := false
for _, e := range errs {
if containsStr(e.Message, "tcp.routers") && containsStr(e.Message, "rule") {
found = true
}
}
if !found {
t.Fatalf("expected tcp router rule error, got %v", errs)
}
}
func TestValidateContent_UDPUnknownField(t *testing.T) {
yaml := `udp:
routers:
r1:
entryPoints: ["udp"]
service: svc1
services:
svc1:
loadBalancer:
servers:
- address: "127.0.0.1:5000"
unknown: foo
`
errs := ValidateContent("app.yml", yaml)
found := false
for _, e := range errs {
if containsStr(e.Message, "udp: unknown key") {
found = true
}
}
if !found {
t.Fatalf("expected udp unknown field error, got %v", errs)
}
}
func TestValidateContent_TLSInvalid(t *testing.T) {
yaml := `tls:
certificates:
- certFile: "/certs/cert.pem"
invalidKey: foo
`
errs := ValidateContent("app.yml", yaml)
found := false
for _, e := range errs {
if containsStr(e.Message, "tls: unknown") {
found = true
}
}
if !found {
t.Fatalf("expected tls unknown key error, got %v", errs)
}
}
func TestValidateContent_UnknownNestedHTTP(t *testing.T) {
yaml := `http:
routers:
r1:
rule: "Host(` + "`a.com`" + `)"
service: s1
services:
s1:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
unknownSection: foo
`
errs := ValidateContent("app.yml", yaml)
found := false
for _, e := range errs {
if containsStr(e.Message, "http: unknown key") {
found = true
}
}
if !found {
t.Fatalf("expected http unknown nested error, got %v", errs)
}
}
func TestValidateContent_ServiceMissingURL(t *testing.T) {
yaml := `http:
routers:
r1:
rule: "Host(` + "`a.com`" + `)"
service: s1
services:
s1:
loadBalancer:
servers:
- weight: 1
`
errs := ValidateContent("app.yml", yaml)
found := false
for _, e := range errs {
if containsStr(e.Message, "missing 'url'") {
found = true
}
}
if !found {
t.Fatalf("expected missing url error, got %v", errs)
}
}
func containsStr(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}