Add Certificates page and Traefik API frontend client
This commit is contained in:
parent
9e4c612dcb
commit
7fc90524b5
40 changed files with 6585 additions and 359 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
110
backend/internal/config/file/diff_test.go
Normal file
110
backend/internal/config/file/diff_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
198
backend/internal/config/file/validate_traefik_test.go
Normal file
198
backend/internal/config/file/validate_traefik_test.go
Normal 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
|
||||
})()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue