mirror of
https://github.com/lukaszraczylo/jobs-manager-operator.git
synced 2026-06-05 22:33:44 +00:00
2b36071647
* Multiple fixes - add goreleaser to the build / release process - add kubectl plugin for job graphs visualization - add installation scripts - update dependencies * Update the release & CRD content. * Next set of improvements. Code Quality - Label constants: Added LabelWorkflowName, LabelGroupName, LabelJobName, LabelJobID in controllers/definitions.go - Removed commented debug code: Cleaned up dead code from multiple files - Removed unused dependencyTree field: Cleaned connPackage struct - Fixed snake_case variables: Changed to camelCase (runGroup, groupDep, runJob, jobDep, k8sJob) Kubernetes Best Practices - Finalizers: Implemented handleDeletion() and deleteChildJobs() for proper cleanup - Status enum validation: Added +kubebuilder:validation:Enum=pending;running;succeeded;failed;aborted - ImagePullPolicy default: Created getImagePullPolicy() helper that defaults to IfNotPresent - Resource limits support: Added Resources *corev1.ResourceRequirements to ManagedJobParameters Observability - Prometheus metrics: Created controllers/metrics.go with counters (jobs created/succeeded/failed), histogram (reconciliation duration), and gauge (active jobs) - Structured logging: Added logger field to connPackage, used context-based logging throughout Configuration - Leader election ID: Made configurable via --leader-election-id flag - Development mode: Made configurable via --dev-mode flag and LOG_LEVEL env var Performance - Dependency lookup optimization: Changed from O(n*m) to O(1) using lookup maps (jobDepMap, groupDepMap) - Reconciliation backoff: Added RequeueAfter: 30*time.Second when workflow is running Documentation & Testing - Godoc documentation: Added comprehensive comments to API types and controller - Unit tests: Added helpers_test.go with tests for all helper functions - Integration tests: Added managedjob_controller_test.go with Ginkgo/Gomega tests * Add the helm chart release. * Add reasonable test coverage.
422 lines
10 KiB
Go
422 lines
10 KiB
Go
package visualization
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
type RendererTestSuite struct {
|
|
suite.Suite
|
|
}
|
|
|
|
func TestRendererSuite(t *testing.T) {
|
|
suite.Run(t, new(RendererTestSuite))
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestNewRenderer() {
|
|
r := NewRenderer(true)
|
|
s.NotNil(r)
|
|
s.True(r.useColor)
|
|
|
|
r2 := NewRenderer(false)
|
|
s.NotNil(r2)
|
|
s.False(r2.useColor)
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestRender_SimpleTree() {
|
|
r := NewRenderer(false)
|
|
tree := NewStatusTree("root")
|
|
|
|
output := r.Render(tree)
|
|
|
|
s.Contains(output, "root")
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestRender_WithStatus() {
|
|
r := NewRenderer(false)
|
|
tree := NewStatusTreeWithStatus("workflow", StatusRunning)
|
|
|
|
output := r.Render(tree)
|
|
|
|
s.Contains(output, "workflow")
|
|
s.Contains(output, "[running]")
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestRender_WithChildren() {
|
|
r := NewRenderer(false)
|
|
tree := NewStatusTree("root")
|
|
tree.Add("child1")
|
|
tree.Add("child2")
|
|
|
|
output := r.Render(tree)
|
|
|
|
s.Contains(output, "root")
|
|
s.Contains(output, "child1")
|
|
s.Contains(output, "child2")
|
|
// Check for tree characters
|
|
s.Contains(output, "├──")
|
|
s.Contains(output, "└──")
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestRender_NestedChildren() {
|
|
r := NewRenderer(false)
|
|
tree := NewStatusTree("workflow")
|
|
group := tree.Add("group1")
|
|
group.Add("job1")
|
|
group.Add("job2")
|
|
|
|
output := r.Render(tree)
|
|
|
|
s.Contains(output, "workflow")
|
|
s.Contains(output, "group1")
|
|
s.Contains(output, "job1")
|
|
s.Contains(output, "job2")
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestRender_AllStatusColors() {
|
|
r := NewRenderer(false)
|
|
tree := NewStatusTree("workflow")
|
|
tree.AddWithStatus("pending-job", StatusPending)
|
|
tree.AddWithStatus("running-job", StatusRunning)
|
|
tree.AddWithStatus("succeeded-job", StatusSucceeded)
|
|
tree.AddWithStatus("failed-job", StatusFailed)
|
|
tree.AddWithStatus("aborted-job", StatusAborted)
|
|
tree.AddWithStatus("unknown-job", StatusUnknown)
|
|
|
|
output := r.Render(tree)
|
|
|
|
s.Contains(output, "[pending]")
|
|
s.Contains(output, "[running]")
|
|
s.Contains(output, "[succeeded]")
|
|
s.Contains(output, "[failed]")
|
|
s.Contains(output, "[aborted]")
|
|
s.Contains(output, "[unknown]")
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestRenderDependency_Job() {
|
|
result := RenderDependency("init-job", false)
|
|
s.Equal("depends on: init-job", result)
|
|
}
|
|
|
|
func (s *RendererTestSuite) TestRenderDependency_Group() {
|
|
result := RenderDependency("init-group", true)
|
|
s.Equal("depends on group: init-group", result)
|
|
}
|
|
|
|
// ==================== MATRIX TEST: RENDER SCENARIOS ====================
|
|
|
|
func TestRenderer_StatusRendering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "pending_status",
|
|
status: StatusPending,
|
|
expected: "[pending]",
|
|
},
|
|
{
|
|
name: "running_status",
|
|
status: StatusRunning,
|
|
expected: "[running]",
|
|
},
|
|
{
|
|
name: "succeeded_status",
|
|
status: StatusSucceeded,
|
|
expected: "[succeeded]",
|
|
},
|
|
{
|
|
name: "failed_status",
|
|
status: StatusFailed,
|
|
expected: "[failed]",
|
|
},
|
|
{
|
|
name: "aborted_status",
|
|
status: StatusAborted,
|
|
expected: "[aborted]",
|
|
},
|
|
{
|
|
name: "unknown_status",
|
|
status: StatusUnknown,
|
|
expected: "[unknown]",
|
|
},
|
|
{
|
|
name: "custom_status",
|
|
status: "custom",
|
|
expected: "[custom]",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := NewRenderer(false)
|
|
tree := NewStatusTreeWithStatus("node", tt.status)
|
|
|
|
output := r.Render(tree)
|
|
|
|
assert.Contains(t, output, tt.expected)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderer_TreeStructure(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
buildTree func() *StatusTree
|
|
contains []string
|
|
}{
|
|
{
|
|
name: "single_node",
|
|
buildTree: func() *StatusTree {
|
|
return NewStatusTree("root")
|
|
},
|
|
contains: []string{"root"},
|
|
},
|
|
{
|
|
name: "parent_with_children",
|
|
buildTree: func() *StatusTree {
|
|
tree := NewStatusTree("parent")
|
|
tree.Add("child1")
|
|
tree.Add("child2")
|
|
return tree
|
|
},
|
|
contains: []string{"parent", "child1", "child2", "├──", "└──"},
|
|
},
|
|
{
|
|
name: "deep_nesting",
|
|
buildTree: func() *StatusTree {
|
|
tree := NewStatusTree("level1")
|
|
l2 := tree.Add("level2")
|
|
l3 := l2.Add("level3")
|
|
l3.Add("level4")
|
|
return tree
|
|
},
|
|
contains: []string{"level1", "level2", "level3", "level4"},
|
|
},
|
|
{
|
|
name: "workflow_structure",
|
|
buildTree: func() *StatusTree {
|
|
tree := NewStatusTreeWithStatus("my-workflow", StatusRunning)
|
|
g1 := tree.AddWithStatus("group1", StatusSucceeded)
|
|
g1.AddWithStatus("job1", StatusSucceeded)
|
|
g2 := tree.AddWithStatus("group2", StatusRunning)
|
|
g2.Add(RenderDependency("group1", true))
|
|
g2.AddWithStatus("job2", StatusRunning)
|
|
return tree
|
|
},
|
|
contains: []string{
|
|
"my-workflow", "[running]",
|
|
"group1", "[succeeded]",
|
|
"job1",
|
|
"group2",
|
|
"depends on group: group1",
|
|
"job2",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := NewRenderer(false)
|
|
tree := tt.buildTree()
|
|
|
|
output := r.Render(tree)
|
|
|
|
for _, expected := range tt.contains {
|
|
assert.Contains(t, output, expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderer_ColorMode(t *testing.T) {
|
|
tree := NewStatusTreeWithStatus("workflow", StatusSucceeded)
|
|
|
|
// Without color
|
|
rNoColor := NewRenderer(false)
|
|
outputNoColor := rNoColor.Render(tree)
|
|
assert.Contains(t, outputNoColor, "[succeeded]")
|
|
|
|
// With color - output should still contain the text (ANSI codes are transparent to Contains)
|
|
rColor := NewRenderer(true)
|
|
outputColor := rColor.Render(tree)
|
|
assert.NotEmpty(t, outputColor)
|
|
}
|
|
|
|
func TestRenderer_ComplexWorkflow(t *testing.T) {
|
|
r := NewRenderer(false)
|
|
|
|
// Build a complex workflow tree
|
|
workflow := NewStatusTreeWithStatus("production-deploy", StatusRunning)
|
|
|
|
// Setup phase
|
|
setup := workflow.AddWithStatus("setup", StatusSucceeded)
|
|
setup.AddWithStatus("configure-aws", StatusSucceeded)
|
|
setup.AddWithStatus("validate-config", StatusSucceeded)
|
|
|
|
// Build phase
|
|
build := workflow.AddWithStatus("build", StatusSucceeded)
|
|
build.Add(RenderDependency("setup", true))
|
|
build.AddWithStatus("compile-frontend", StatusSucceeded)
|
|
build.AddWithStatus("compile-backend", StatusSucceeded)
|
|
build.AddWithStatus("run-unit-tests", StatusSucceeded)
|
|
|
|
// Deploy phase
|
|
deploy := workflow.AddWithStatus("deploy", StatusRunning)
|
|
deploy.Add(RenderDependency("build", true))
|
|
staging := deploy.AddWithStatus("deploy-staging", StatusSucceeded)
|
|
staging.Add(RenderDependency("compile-frontend", false))
|
|
integration := deploy.AddWithStatus("run-integration-tests", StatusRunning)
|
|
integration.Add(RenderDependency("deploy-staging", false))
|
|
prod := deploy.AddWithStatus("deploy-production", StatusPending)
|
|
prod.Add(RenderDependency("run-integration-tests", false))
|
|
|
|
// Cleanup phase
|
|
cleanup := workflow.AddWithStatus("cleanup", StatusPending)
|
|
cleanup.Add(RenderDependency("deploy", true))
|
|
cleanup.AddWithStatus("remove-staging", StatusPending)
|
|
|
|
output := r.Render(workflow)
|
|
|
|
// Verify structure
|
|
expectedPhrases := []string{
|
|
"production-deploy",
|
|
"setup",
|
|
"configure-aws",
|
|
"build",
|
|
"compile-frontend",
|
|
"deploy",
|
|
"deploy-staging",
|
|
"run-integration-tests",
|
|
"deploy-production",
|
|
"cleanup",
|
|
"depends on group: setup",
|
|
"depends on group: build",
|
|
"depends on: deploy-staging",
|
|
}
|
|
|
|
for _, phrase := range expectedPhrases {
|
|
assert.Contains(t, output, phrase, "output should contain: %s", phrase)
|
|
}
|
|
|
|
// Verify line structure
|
|
lines := strings.Split(output, "\n")
|
|
assert.Greater(t, len(lines), 10, "should have multiple lines")
|
|
}
|
|
|
|
func TestRenderDependency_Matrix(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
depName string
|
|
isGroup bool
|
|
expected string
|
|
}{
|
|
{
|
|
name: "job_dependency",
|
|
depName: "init-job",
|
|
isGroup: false,
|
|
expected: "depends on: init-job",
|
|
},
|
|
{
|
|
name: "group_dependency",
|
|
depName: "setup-group",
|
|
isGroup: true,
|
|
expected: "depends on group: setup-group",
|
|
},
|
|
{
|
|
name: "long_job_name",
|
|
depName: "very-long-job-name-with-many-parts",
|
|
isGroup: false,
|
|
expected: "depends on: very-long-job-name-with-many-parts",
|
|
},
|
|
{
|
|
name: "empty_name",
|
|
depName: "",
|
|
isGroup: false,
|
|
expected: "depends on: ",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := RenderDependency(tt.depName, tt.isGroup)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test that tree rendering handles edge cases
|
|
func TestRenderer_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
buildTree func() *StatusTree
|
|
verify func(*testing.T, string)
|
|
}{
|
|
{
|
|
name: "empty_text_node",
|
|
buildTree: func() *StatusTree {
|
|
return NewStatusTree("")
|
|
},
|
|
verify: func(t *testing.T, output string) {
|
|
assert.NotEmpty(t, output)
|
|
},
|
|
},
|
|
{
|
|
name: "special_characters_in_text",
|
|
buildTree: func() *StatusTree {
|
|
tree := NewStatusTree("workflow-v1.2.3")
|
|
tree.Add("job_with_underscore")
|
|
tree.Add("job.with.dots")
|
|
return tree
|
|
},
|
|
verify: func(t *testing.T, output string) {
|
|
assert.Contains(t, output, "workflow-v1.2.3")
|
|
assert.Contains(t, output, "job_with_underscore")
|
|
assert.Contains(t, output, "job.with.dots")
|
|
},
|
|
},
|
|
{
|
|
name: "many_siblings",
|
|
buildTree: func() *StatusTree {
|
|
tree := NewStatusTree("parent")
|
|
for i := 0; i < 20; i++ {
|
|
tree.Add("child")
|
|
}
|
|
return tree
|
|
},
|
|
verify: func(t *testing.T, output string) {
|
|
// Should have 19 middle items and 1 last item
|
|
assert.Equal(t, 19, strings.Count(output, "├──"))
|
|
assert.Equal(t, 1, strings.Count(output, "└──"))
|
|
},
|
|
},
|
|
{
|
|
name: "unicode_in_text",
|
|
buildTree: func() *StatusTree {
|
|
tree := NewStatusTree("🚀 deployment")
|
|
tree.Add("✅ verified")
|
|
tree.Add("⏳ pending")
|
|
return tree
|
|
},
|
|
verify: func(t *testing.T, output string) {
|
|
assert.Contains(t, output, "🚀")
|
|
assert.Contains(t, output, "✅")
|
|
assert.Contains(t, output, "⏳")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := NewRenderer(false)
|
|
tree := tt.buildTree()
|
|
output := r.Render(tree)
|
|
tt.verify(t, output)
|
|
})
|
|
}
|
|
}
|