Files
jobs-manager-operator/controllers/crd_dependency_tree_test.go
T
lukaszraczylo 2b36071647 Multiple fixes (#29)
* 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.
2025-12-17 22:33:23 +00:00

369 lines
9.2 KiB
Go

package controllers
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
jobsmanagerv1beta1 "raczylo.com/jobs-manager-operator/api/v1beta1"
)
// TreeTestSuite tests the Tree implementation
type TreeTestSuite struct {
suite.Suite
}
func TestTreeInterfaceSuite(t *testing.T) {
suite.Run(t, new(TreeTestSuite))
}
func (s *TreeTestSuite) TestNew() {
tree := New("root")
s.Equal("root", tree.Text())
s.Empty(tree.Items())
}
func (s *TreeTestSuite) TestAdd() {
root := New("root")
child := root.Add("child")
s.Equal("child", child.Text())
s.Len(root.Items(), 1)
}
func (s *TreeTestSuite) TestAddTree() {
root := New("root")
subtree := New("subtree")
subtree.Add("subtree-child")
root.AddTree(subtree)
s.Len(root.Items(), 1)
s.Equal("subtree", root.Items()[0].Text())
}
func (s *TreeTestSuite) TestItems() {
root := New("root")
root.Add("child1")
root.Add("child2")
root.Add("child3")
items := root.Items()
s.Len(items, 3)
}
func (s *TreeTestSuite) TestText() {
tree := New("test-text")
s.Equal("test-text", tree.Text())
}
func (s *TreeTestSuite) TestPrint_Simple() {
tree := New("root")
output := tree.Print()
s.Contains(output, "root")
}
func (s *TreeTestSuite) TestPrint_WithChildren() {
tree := New("root")
tree.Add("child1")
tree.Add("child2")
output := tree.Print()
s.Contains(output, "root")
s.Contains(output, "child1")
s.Contains(output, "child2")
s.Contains(output, "├──")
s.Contains(output, "└──")
}
func (s *TreeTestSuite) TestPrint_NestedChildren() {
tree := New("root")
child := tree.Add("child")
child.Add("grandchild1")
child.Add("grandchild2")
output := tree.Print()
s.Contains(output, "root")
s.Contains(output, "child")
s.Contains(output, "grandchild1")
s.Contains(output, "grandchild2")
}
func (s *TreeTestSuite) TestPrint_WorkflowStructure() {
workflow := New("my-workflow")
group1 := workflow.Add("init-group")
group1.Add("setup-job")
group1.Add("config-job")
group2 := workflow.Add("build-group")
group2.Add("Depends on group: init-group")
group2.Add("compile-job")
output := workflow.Print()
s.Contains(output, "my-workflow")
s.Contains(output, "init-group")
s.Contains(output, "setup-job")
s.Contains(output, "build-group")
s.Contains(output, "Depends on group: init-group")
}
// ==================== GENERATE DEPENDENCY TREE TESTS ====================
type DependencyTreeTestSuite struct {
suite.Suite
ctx context.Context
cancel context.CancelFunc
client *MockClient
reconciler *ManagedJobReconciler
}
func (s *DependencyTreeTestSuite) SetupTest() {
s.ctx, s.cancel = context.WithCancel(context.Background())
s.client = NewMockClient()
s.reconciler = &ManagedJobReconciler{
Client: s.client,
Scheme: s.client.Scheme(),
Recorder: NewFakeRecorder(),
}
}
func (s *DependencyTreeTestSuite) TearDownTest() {
s.cancel()
}
func TestDependencyTreeSuite(t *testing.T) {
suite.Run(t, new(DependencyTreeTestSuite))
}
func (s *DependencyTreeTestSuite) newConnPackage(mj *jobsmanagerv1beta1.ManagedJob) *connPackage {
cp := &connPackage{
ctx: s.ctx,
r: s.reconciler,
mj: mj,
req: ctrl.Request{
NamespacedName: types.NamespacedName{
Name: mj.Name,
Namespace: mj.Namespace,
},
},
logger: zap.New(),
}
cp.buildDependencyMaps()
return cp
}
func (s *DependencyTreeTestSuite) TestGenerateDependencyTree_SingleGroup() {
mj := NewTestManagedJob("workflow", "default", []*jobsmanagerv1beta1.ManagedJobGroup{
NewTestGroup("group1", []*jobsmanagerv1beta1.ManagedJobDefinition{
NewTestJobDef("job1", "busybox"),
NewTestJobDef("job2", "busybox"),
}),
})
controllerutil.AddFinalizer(mj, FinalizerName)
s.client.AddManagedJob(mj)
cp := s.newConnPackage(mj)
cp.generateDependencyTree()
// Sequential jobs should have dependencies
s.NotEmpty(mj.Spec.Groups[0].Jobs[1].Dependencies)
}
func (s *DependencyTreeTestSuite) TestGenerateDependencyTree_ParallelJobs() {
mj := &jobsmanagerv1beta1.ManagedJob{
ObjectMeta: NewTestManagedJob("workflow", "default", nil).ObjectMeta,
Spec: jobsmanagerv1beta1.ManagedJobSpec{
Groups: []*jobsmanagerv1beta1.ManagedJobGroup{
{
Name: "group1",
Jobs: []*jobsmanagerv1beta1.ManagedJobDefinition{
{Name: "job1", Image: "busybox", Parallel: true, Status: ExecutionStatusPending},
{Name: "job2", Image: "busybox", Parallel: true, Status: ExecutionStatusPending},
},
Status: ExecutionStatusPending,
},
},
},
}
controllerutil.AddFinalizer(mj, FinalizerName)
s.client.AddManagedJob(mj)
cp := s.newConnPackage(mj)
cp.generateDependencyTree()
// Parallel jobs should not have dependencies on each other
s.Empty(mj.Spec.Groups[0].Jobs[0].Dependencies)
s.Empty(mj.Spec.Groups[0].Jobs[1].Dependencies)
}
func (s *DependencyTreeTestSuite) TestGenerateDependencyTree_MultipleGroups() {
mj := NewTestManagedJob("workflow", "default", []*jobsmanagerv1beta1.ManagedJobGroup{
NewTestGroup("group1", []*jobsmanagerv1beta1.ManagedJobDefinition{
NewTestJobDef("job1", "busybox"),
}),
NewTestGroup("group2", []*jobsmanagerv1beta1.ManagedJobDefinition{
NewTestJobDef("job2", "busybox"),
}),
})
controllerutil.AddFinalizer(mj, FinalizerName)
s.client.AddManagedJob(mj)
cp := s.newConnPackage(mj)
cp.generateDependencyTree()
// Sequential groups should have dependencies
s.NotEmpty(mj.Spec.Groups[1].Dependencies)
}
func (s *DependencyTreeTestSuite) TestGenerateDependencyTree_ParallelGroups() {
mj := &jobsmanagerv1beta1.ManagedJob{
ObjectMeta: NewTestManagedJob("workflow", "default", nil).ObjectMeta,
Spec: jobsmanagerv1beta1.ManagedJobSpec{
Groups: []*jobsmanagerv1beta1.ManagedJobGroup{
{
Name: "group1",
Jobs: []*jobsmanagerv1beta1.ManagedJobDefinition{{Name: "job1", Image: "busybox", Status: ExecutionStatusPending}},
Parallel: true,
Status: ExecutionStatusPending,
},
{
Name: "group2",
Jobs: []*jobsmanagerv1beta1.ManagedJobDefinition{{Name: "job2", Image: "busybox", Status: ExecutionStatusPending}},
Parallel: true,
Status: ExecutionStatusPending,
},
},
},
}
controllerutil.AddFinalizer(mj, FinalizerName)
s.client.AddManagedJob(mj)
cp := s.newConnPackage(mj)
cp.generateDependencyTree()
// Parallel groups should not have dependencies on each other
s.Empty(mj.Spec.Groups[0].Dependencies)
s.Empty(mj.Spec.Groups[1].Dependencies)
}
func (s *DependencyTreeTestSuite) TestCheckIfPresentInDependencies_Found() {
mj := NewTestManagedJob("workflow", "default", nil)
cp := s.newConnPackage(mj)
deps := []*jobsmanagerv1beta1.ManagedJobDependencies{
{Name: "dep1", Status: ExecutionStatusPending},
{Name: "dep2", Status: ExecutionStatusPending},
}
s.True(cp.checkIfPresentInDependencies(deps, "dep1"))
s.True(cp.checkIfPresentInDependencies(deps, "dep2"))
}
func (s *DependencyTreeTestSuite) TestCheckIfPresentInDependencies_NotFound() {
mj := NewTestManagedJob("workflow", "default", nil)
cp := s.newConnPackage(mj)
deps := []*jobsmanagerv1beta1.ManagedJobDependencies{
{Name: "dep1", Status: ExecutionStatusPending},
}
s.False(cp.checkIfPresentInDependencies(deps, "dep3"))
}
func (s *DependencyTreeTestSuite) TestCheckIfPresentInDependencies_Empty() {
mj := NewTestManagedJob("workflow", "default", nil)
cp := s.newConnPackage(mj)
var deps []*jobsmanagerv1beta1.ManagedJobDependencies
s.False(cp.checkIfPresentInDependencies(deps, "any"))
}
// ==================== MATRIX TEST: TREE PRINTING ====================
func TestTreePrint_Matrix(t *testing.T) {
tests := []struct {
name string
buildTree func() Tree
contains []string
}{
{
name: "single_node",
buildTree: func() Tree {
return New("root")
},
contains: []string{"root"},
},
{
name: "two_children",
buildTree: func() Tree {
tree := New("parent")
tree.Add("child1")
tree.Add("child2")
return tree
},
contains: []string{"parent", "├──", "└──", "child1", "child2"},
},
{
name: "deep_nesting",
buildTree: func() Tree {
tree := New("l1")
l2 := tree.Add("l2")
l3 := l2.Add("l3")
l3.Add("l4")
return tree
},
contains: []string{"l1", "l2", "l3", "l4"},
},
{
name: "workflow_example",
buildTree: func() Tree {
wf := New("my-workflow")
g1 := wf.Add("init")
g1.Add("setup-db")
g1.Add("setup-cache")
g2 := wf.Add("build")
g2.Add("Depends on group: init")
g2.Add("compile")
return wf
},
contains: []string{"my-workflow", "init", "setup-db", "build", "Depends on group: init"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tree := tt.buildTree()
output := tree.Print()
for _, expected := range tt.contains {
assert.Contains(t, output, expected)
}
})
}
}
func TestTreePrint_MultilineText(t *testing.T) {
tree := New("root")
tree.Add("line1\nline2\nline3")
output := tree.Print()
// Should have all three lines
assert.True(t, strings.Contains(output, "line1"))
assert.True(t, strings.Contains(output, "line2"))
assert.True(t, strings.Contains(output, "line3"))
}