diff --git a/Makefile b/Makefile index f75e7a6..f10033b 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,18 @@ test-e2e: manifests generate fmt vet ## Run the e2e tests. Expected an isolated } go test ./test/e2e/ -v -ginkgo.v +.PHONY: test-integration +test-integration: manifests generate fmt vet ## Run the integration tests. Requires a running Kubernetes cluster. + @command -v kubectl >/dev/null 2>&1 || { \ + echo "kubectl is not installed. Please install kubectl manually."; \ + exit 1; \ + } + @kubectl cluster-info >/dev/null 2>&1 || { \ + echo "No Kubernetes cluster is accessible. Please ensure kubectl is configured with a valid cluster."; \ + exit 1; \ + } + go test ./test/integration/ -v -ginkgo.v + .PHONY: lint lint: golangci-lint ## Run golangci-lint linter $(GOLANGCI_LINT) run diff --git a/api/v1alpha1/challenge_types.go b/api/v1alpha1/challenge_types.go deleted file mode 100644 index 05f1692..0000000 --- a/api/v1alpha1/challenge_types.go +++ /dev/null @@ -1,79 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - -// ChallengeSpec defines the desired state of Challenge. -type ChallengeSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file - Namespace string `json:"namespace"` - - // Definition: ChallengeDefinition 이름 - Definition string `json:"definition"` -} - -// ChallengeStatus defines the observed state of Challenge. -type ChallengeStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file - - // StartedAt: Challenge 시작 시간 - StartedAt *metav1.Time `json:"startedAt,omitempty"` - - // CurrentStatus: Challenge 현재 상태 - CurrentStatus CurrentStatus `json:"currentStatus,omitempty"` - - // isOne는 영속성을 나타낸다. - // +optional - IsOne bool `json:"isOne,omitempty"` - - // Endpoint: Challenge의 Endpoint - // 외부에 노출될 포트 번호가 저장됩니다. - Endpoint int `json:"endpoint,omitempty"` -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:skipversion -// Challenge is the Schema for the challenges API. -type Challenge struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec ChallengeSpec `json:"spec,omitempty"` - Status ChallengeStatus `json:"status,omitempty"` -} - -// +kubebuilder:object:root=true - -// ChallengeList contains a list of Challenge. -type ChallengeList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []Challenge `json:"items"` -} - -func init() { - SchemeBuilder.Register(&Challenge{}, &ChallengeList{}) -} diff --git a/api/v1alpha1/challengedefinition_types.go b/api/v1alpha1/challengedefinition_types.go deleted file mode 100644 index d40369a..0000000 --- a/api/v1alpha1/challengedefinition_types.go +++ /dev/null @@ -1,102 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1alpha1 - -import ( - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - -// ChallengeDefinitionSpec defines the desired state of ChallengeDefinition. -type ChallengeDefinitionSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file - - // IsOne: 하나만 생성할 경우 - // False일 경우 일정 시간 내에서만 작동된다. - IsOne bool `json:"isOne,omitempty"` - - // Components: Challenge를 구성하는 컴포넌트들 - Components []Component `json:"components,omitempty"` -} - -// Component 는 이름과 리소스를 정의 -type Component struct { - Name string `json:"name,omitempty"` - Deployment *CustomDeployment `json:"deployment,omitempty"` - Service *corev1.Service `json:"service,omitempty"` -} - -// Deployment 관련 구조체 -// CustomDeploymentSpec 는 Replicas와 Template을 정의 -// 자세한 내용은 Kubernetes Deployment API 문서 참고 -// https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ - -type CustomDeployment struct { - Spec CustomDeploymentSpec `json:"spec,omitempty"` -} - -type CustomDeploymentSpec struct { - Replicas int32 `json:"replicas,omitempty"` - Template CustomPodTemplateSpec `json:"template,omitempty"` -} - -type CustomPodTemplateSpec struct { - Spec CustomPodSpec `json:"spec,omitempty"` -} - -type CustomPodSpec struct { - // +optional - NodeSelector map[string]string `json:"nodeSelector,omitempty"` - Containers []corev1.Container `json:"containers,omitempty"` -} - -// ChallengeDefinitionStatus defines the observed state of ChallengeDefinition. -type ChallengeDefinitionStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file - -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:skipversion - -// ChallengeDefinition is the Schema for the challengedefinitions API. -type ChallengeDefinition struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec ChallengeDefinitionSpec `json:"spec,omitempty"` - Status ChallengeDefinitionStatus `json:"status,omitempty"` -} - -// +kubebuilder:object:root=true - -// ChallengeDefinitionList contains a list of ChallengeDefinition. -type ChallengeDefinitionList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []ChallengeDefinition `json:"items"` -} - -func init() { - SchemeBuilder.Register(&ChallengeDefinition{}, &ChallengeDefinitionList{}) -} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go deleted file mode 100644 index 9c52079..0000000 --- a/api/v1alpha1/groupversion_info.go +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Package v1alpha1 contains API Schema definitions for the apps v1alpha1 API group. -// +kubebuilder:object:generate=true -// +groupName=apps.hexactf.io -package v1alpha1 - -import ( - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" -) - -var ( - // GroupVersion is group version used to register these objects. - GroupVersion = schema.GroupVersion{Group: "apps.hexactf.io", Version: "v1alpha1"} - - // SchemeBuilder is used to add go types to the GroupVersionKind scheme. - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} - - // AddToScheme adds the types in this group-version to the given scheme. - AddToScheme = SchemeBuilder.AddToScheme -) diff --git a/api/v1alpha1/status.go b/api/v1alpha1/status.go deleted file mode 100644 index 760fc86..0000000 --- a/api/v1alpha1/status.go +++ /dev/null @@ -1,73 +0,0 @@ -package v1alpha1 - -type CurrentStatus struct { - State string `json:"state,omitempty"` - ErrorMsg string `json:"errorMsg"` -} - -func NewCurrentStatus() *CurrentStatus { - return &CurrentStatus{ - State: "None", - ErrorMsg: "", - } -} - -func (c *CurrentStatus) String() string { - return c.State -} - -func (c *CurrentStatus) SetCreating() { - c.State = "Creating" -} - -func (c *CurrentStatus) SetRunning() { - c.State = "Running" -} - -func (c *CurrentStatus) SetError(err error) { - c.State = "Error" - c.ErrorMsg = err.Error() -} - -func (c *CurrentStatus) SetTerminating() { - c.State = "Terminating" -} - -func (c *CurrentStatus) SetDeleted() { - c.State = "Deleted" -} - -func (c *CurrentStatus) SetNone() { - c.State = "None" -} - -func (c *CurrentStatus) IsNothing() bool { - return c.State == "" -} -func (c *CurrentStatus) IsNone() bool { - return c.State == "None" -} - -func (c *CurrentStatus) IsCreated() bool { - return c.State == "Created" -} - -func (c *CurrentStatus) IsRunning() bool { - return c.State == "Running" -} - -func (c *CurrentStatus) IsError() bool { - return c.State == "Error" -} - -func (c *CurrentStatus) IsTerminating() bool { - return c.State == "Terminating" -} - -func (c *CurrentStatus) IsDeleted() bool { - return c.State == "Deleted" -} - -func (c *CurrentStatus) IsTerminated() bool { - return c.State == "Terminating" -} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go deleted file mode 100644 index b21e3c3..0000000 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ /dev/null @@ -1,333 +0,0 @@ -//go:build !ignore_autogenerated - -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Code generated by controller-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - "k8s.io/api/core/v1" - runtime "k8s.io/apimachinery/pkg/runtime" -) - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Challenge) DeepCopyInto(out *Challenge) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Challenge. -func (in *Challenge) DeepCopy() *Challenge { - if in == nil { - return nil - } - out := new(Challenge) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *Challenge) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChallengeDefinition) DeepCopyInto(out *ChallengeDefinition) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChallengeDefinition. -func (in *ChallengeDefinition) DeepCopy() *ChallengeDefinition { - if in == nil { - return nil - } - out := new(ChallengeDefinition) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ChallengeDefinition) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChallengeDefinitionList) DeepCopyInto(out *ChallengeDefinitionList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]ChallengeDefinition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChallengeDefinitionList. -func (in *ChallengeDefinitionList) DeepCopy() *ChallengeDefinitionList { - if in == nil { - return nil - } - out := new(ChallengeDefinitionList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ChallengeDefinitionList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChallengeDefinitionSpec) DeepCopyInto(out *ChallengeDefinitionSpec) { - *out = *in - if in.Components != nil { - in, out := &in.Components, &out.Components - *out = make([]Component, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChallengeDefinitionSpec. -func (in *ChallengeDefinitionSpec) DeepCopy() *ChallengeDefinitionSpec { - if in == nil { - return nil - } - out := new(ChallengeDefinitionSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChallengeDefinitionStatus) DeepCopyInto(out *ChallengeDefinitionStatus) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChallengeDefinitionStatus. -func (in *ChallengeDefinitionStatus) DeepCopy() *ChallengeDefinitionStatus { - if in == nil { - return nil - } - out := new(ChallengeDefinitionStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChallengeList) DeepCopyInto(out *ChallengeList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]Challenge, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChallengeList. -func (in *ChallengeList) DeepCopy() *ChallengeList { - if in == nil { - return nil - } - out := new(ChallengeList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ChallengeList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChallengeSpec) DeepCopyInto(out *ChallengeSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChallengeSpec. -func (in *ChallengeSpec) DeepCopy() *ChallengeSpec { - if in == nil { - return nil - } - out := new(ChallengeSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChallengeStatus) DeepCopyInto(out *ChallengeStatus) { - *out = *in - if in.StartedAt != nil { - in, out := &in.StartedAt, &out.StartedAt - *out = (*in).DeepCopy() - } - out.CurrentStatus = in.CurrentStatus -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChallengeStatus. -func (in *ChallengeStatus) DeepCopy() *ChallengeStatus { - if in == nil { - return nil - } - out := new(ChallengeStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Component) DeepCopyInto(out *Component) { - *out = *in - if in.Deployment != nil { - in, out := &in.Deployment, &out.Deployment - *out = new(CustomDeployment) - (*in).DeepCopyInto(*out) - } - if in.Service != nil { - in, out := &in.Service, &out.Service - *out = new(v1.Service) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Component. -func (in *Component) DeepCopy() *Component { - if in == nil { - return nil - } - out := new(Component) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CurrentStatus) DeepCopyInto(out *CurrentStatus) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CurrentStatus. -func (in *CurrentStatus) DeepCopy() *CurrentStatus { - if in == nil { - return nil - } - out := new(CurrentStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CustomDeployment) DeepCopyInto(out *CustomDeployment) { - *out = *in - in.Spec.DeepCopyInto(&out.Spec) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomDeployment. -func (in *CustomDeployment) DeepCopy() *CustomDeployment { - if in == nil { - return nil - } - out := new(CustomDeployment) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CustomDeploymentSpec) DeepCopyInto(out *CustomDeploymentSpec) { - *out = *in - in.Template.DeepCopyInto(&out.Template) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomDeploymentSpec. -func (in *CustomDeploymentSpec) DeepCopy() *CustomDeploymentSpec { - if in == nil { - return nil - } - out := new(CustomDeploymentSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CustomPodSpec) DeepCopyInto(out *CustomPodSpec) { - *out = *in - if in.NodeSelector != nil { - in, out := &in.NodeSelector, &out.NodeSelector - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Containers != nil { - in, out := &in.Containers, &out.Containers - *out = make([]v1.Container, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomPodSpec. -func (in *CustomPodSpec) DeepCopy() *CustomPodSpec { - if in == nil { - return nil - } - out := new(CustomPodSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CustomPodTemplateSpec) DeepCopyInto(out *CustomPodTemplateSpec) { - *out = *in - in.Spec.DeepCopyInto(&out.Spec) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomPodTemplateSpec. -func (in *CustomPodTemplateSpec) DeepCopy() *CustomPodTemplateSpec { - if in == nil { - return nil - } - out := new(CustomPodTemplateSpec) - in.DeepCopyInto(out) - return out -} diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml index 2642501..2c863c2 100644 --- a/config/crd/kustomizeconfig.yaml +++ b/config/crd/kustomizeconfig.yaml @@ -19,9 +19,6 @@ varReference: - path: metadata/annotations versions: - - name: v1alpha1 - served: false # 더 이상 제공하지 않음 - storage: false # 저장 버전이 아님 - name: v2alpha1 served: true # 현재 제공 중 storage: true # 저장 버전 diff --git a/go.mod b/go.mod index 4aa6715..651fda0 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ require ( github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect go.opentelemetry.io/otel v1.28.0 // indirect diff --git a/go.sum b/go.sum index 472cdce..5b56d0a 100644 --- a/go.sum +++ b/go.sum @@ -160,6 +160,8 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= diff --git a/internal/controller/v1/challenge_controller.go b/internal/controller/v1/challenge_controller.go deleted file mode 100644 index 61ea3b4..0000000 --- a/internal/controller/v1/challenge_controller.go +++ /dev/null @@ -1,275 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "time" - - hexactfproj "github.com/hexactf/challenge-operator/api/v1alpha1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - logr "sigs.k8s.io/controller-runtime/pkg/log" -) - -const ( - challengeDuration = 30 * time.Minute - requeueInterval = 30 * time.Second - warningThreshold = 2 * time.Minute // Time to start warning about impending timeout -) - -var log = logr.Log.WithName("ChallengeController") - -// ChallengeReconciler reconciles a Challenge object -type ChallengeReconciler struct { - client.Client - Scheme *runtime.Scheme - - // KafkaClient is the Kafka producer client - // 중요한 메세지를 Kafka를 통해 보낸다. - KafkaClient *KafkaProducer -} - -// +kubebuilder:rbac:groups=apps.hexactf.io,resources=challenges,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=apps.hexactf.io,resources=challenges/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=apps.hexactf.io,resources=challenges/finalizers,verbs=update -//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=core,resources=services/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=apps,resources=deployments/status,verbs=get;update;patch - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the Challenge object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/reconcile -func (r *ChallengeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - - challenge := hexactfproj.Challenge{} - if err := r.Get(ctx, req.NamespacedName, &challenge); err != nil { - // NotFound 에러 등은 무시 - return ctrl.Result{}, client.IgnoreNotFound(err) - } - - // 이미 삭제 진행중(DeletionTimestamp가 찍힘)이라면 handleDeletion 수행 - if !challenge.DeletionTimestamp.IsZero() { - return r.handleDeletion(ctx, &challenge) - } - - // Finalizer가 없다면 추가 - if !containsString(challenge.Finalizers) { - return r.addFinalizer(ctx, &challenge) - } - - // 처음 생성 시 StartedAt 등 Status 초기화 - if challenge.Status.StartedAt == nil { - crStatusMetric.WithLabelValues(challenge.Labels["apps.hexactf.io/challengeId"], challenge.Name, challenge.Labels["apps.hexactf.io/user"], challenge.Namespace).Set(0) - - if err := r.Get(ctx, req.NamespacedName, &challenge); err != nil { - return r.handleError(ctx, req, &challenge, err) - } - now := metav1.Now() - challenge.Status.StartedAt = &now - challenge.Status.CurrentStatus = *hexactfproj.NewCurrentStatus() - if err := r.Status().Update(ctx, &challenge); err != nil { - log.Error(err, "Failed to initialize status") - return r.handleError(ctx, req, &challenge, err) - } - err := r.KafkaClient.SendStatusChange(challenge.Labels["apps.hexactf.io/user"], challenge.Labels["apps.hexactf.io/challengeId"], "Running") - if err != nil { - log.Error(err, "Failed to send status change message") - return r.handleError(ctx, req, &challenge, err) - } - } - - // 최신 상태로 갱신 - if err := r.Get(ctx, req.NamespacedName, &challenge); err != nil { - // NotFound 에러 등은 무시 - return r.handleError(ctx, req, &challenge, err) - } - - // 현재 상태에 따라 분기 - switch { - case challenge.Status.CurrentStatus.IsNone(): - // 상태를 Creating -> Running 으로 전환 - if err := r.Get(ctx, req.NamespacedName, &challenge); err != nil { - return r.handleError(ctx, req, &challenge, err) - } - - challenge.Status.CurrentStatus.SetCreating() - if err := r.Status().Update(ctx, &challenge); err != nil { - return r.handleError(ctx, req, &challenge, err) - } - - if err := r.Get(ctx, req.NamespacedName, &challenge); err != nil { - return r.handleError(ctx, req, &challenge, err) - } - // 실제 Challenge에 필요한 리소스들(Deployment, Service 등) 생성 로직 - err := r.loadChallengeDefinition(ctx, req, &challenge) - if err != nil { - return r.handleError(ctx, req, &challenge, err) - } - - // 다시 한번 최신화 - if err := r.Get(ctx, req.NamespacedName, &challenge); err != nil { - return r.handleError(ctx, req, &challenge, err) - } - - challenge.Status.CurrentStatus.SetRunning() - now := metav1.Now() - challenge.Status.StartedAt = &now - if err := r.Status().Update(ctx, &challenge); err != nil { - return r.handleError(ctx, req, &challenge, err) - } - // Metrics - crStatusMetric.WithLabelValues(challenge.Labels["apps.hexactf.io/challengeId"], challenge.Name, challenge.Labels["apps.hexactf.io/user"], challenge.Namespace).Set(1) - - // 한 번 더 재큐(Requeue)하여 바로 다음 단계 확인 - return ctrl.Result{Requeue: true}, nil - - case challenge.Status.CurrentStatus.IsRunning(): - // 최신화 - if err := r.Get(ctx, req.NamespacedName, &challenge); err != nil { - return r.handleError(ctx, req, &challenge, err) - } - - // 5분 초과 시 Delete 요청 - if time.Since(challenge.Status.StartedAt.Time) > challengeDuration { - // 아직 DeletionTimestamp가 없다면 Delete 요청 - if challenge.DeletionTimestamp.IsZero() { - - log.Info("Time exceeded; issuing a Delete request", "challenge", challenge.Name) - if err := r.Delete(ctx, &challenge); err != nil { - log.Error(err, "Failed to delete challenge") - return r.handleError(ctx, req, &challenge, err) - } - // Delete 요청 후에는 Kubernetes가 DeletionTimestamp를 설정하고 - // 다시 Reconcile이 호출되면 handleDeletion()이 수행됨 - return ctrl.Result{}, nil - } else { - // 이미 Delete 진행중이면 handleDeletion으로 - return r.handleDeletion(ctx, &challenge) - } - } - - // 이미 삭제 요청(DeletionTimestamp가 존재) 중이라면 handleDeletion으로 - if !challenge.DeletionTimestamp.IsZero() { - return r.handleDeletion(ctx, &challenge) - } - - // 아직 삭제 대상이 아니라면 Running 상태 유지 - err := r.KafkaClient.SendStatusChange(challenge.Labels["apps.hexactf.io/user"], challenge.Labels["apps.hexactf.io/challengeId"], "Running") - if err != nil { - log.Error(err, "Failed to send status change message") - return r.handleError(ctx, req, &challenge, err) - } - - // 주기적으로 다시 Reconcile - return ctrl.Result{RequeueAfter: requeueInterval}, nil - } - - // 그 외 상태 - return ctrl.Result{}, nil -} - -func (r *ChallengeReconciler) handleDeletion(ctx context.Context, challenge *hexactfproj.Challenge) (ctrl.Result, error) { - log.Info("Processing deletion", "challenge", challenge.Name) - crStatusMetric.WithLabelValues(challenge.Labels["apps.hexactf.io/challengeId"], challenge.Name, challenge.Labels["apps.hexactf.io/user"], challenge.Namespace).Set(2) - - // 1. Finalizer가 남아있는지 확인 - if controllerutil.ContainsFinalizer(challenge, "challenge.hexactf.io/finalizer") { - - // 3. 파이널라이저 제거 - controllerutil.RemoveFinalizer(challenge, "challenge.hexactf.io/finalizer") - - // 4. 메타데이터 업데이트 - if err := r.Update(ctx, challenge); err != nil { - log.Error(err, "Failed to remove finalizer") - // 재시도 위해 Requeue - return ctrl.Result{RequeueAfter: time.Second * 5}, err - } - - // 필요하다면 Deleted 이벤트 전송 - err := r.KafkaClient.SendStatusChange( - challenge.Labels["apps.hexactf.io/user"], - challenge.Labels["apps.hexactf.io/challengeId"], - "Deleted", - ) - if err != nil { - log.Error(err, "Failed to send status change message") - // 여기서도 에러 시 재시도 - return ctrl.Result{}, err - } - } - - go func() { - time.Sleep(1 * time.Minute) // scrape_interval이 30초라면 1분 정도 기다리면 안전 - crStatusMetric.DeleteLabelValues(challenge.Labels["apps.hexactf.io/challengeId"], challenge.Name, challenge.Labels["apps.hexactf.io/user"], challenge.Namespace) - }() - log.Info("Successfully completed deletion process") - // 이 시점에서 finalizers가 비어 있으므로, K8s가 오브젝트를 실제 삭제함 - return ctrl.Result{}, nil -} - -// handleError: 상태를 Error로 변경하고 로그 & Kafka 메시지 전송 등 -func (r *ChallengeReconciler) handleError(ctx context.Context, req ctrl.Request, challenge *hexactfproj.Challenge, err error) (ctrl.Result, error) { - // 최신 상태로 갱신 - if err := r.Get(ctx, req.NamespacedName, challenge); err != nil { - // NotFound 에러 등은 무시 - return ctrl.Result{}, client.IgnoreNotFound(err) - } - challenge.Status.CurrentStatus.SetError(err) - - if err := r.Status().Update(ctx, challenge); err != nil { - return ctrl.Result{}, err - } - - crStatusMetric.WithLabelValues(challenge.Labels["apps.hexactf.io/challengeId"], challenge.Name, challenge.Labels["apps.hexactf.io/user"], challenge.Namespace).Set(3) - // 상태를 Error로 전송 - sendErr := r.KafkaClient.SendStatusChange(challenge.Labels["apps.hexactf.io/user"], challenge.Labels["apps.hexactf.io/challengeId"], "Error") - if sendErr != nil { - log.Error(sendErr, "Failed to send status change message") - return ctrl.Result{}, sendErr - } - - // 에러 발생 시 challenge 삭제 - if deleteErr := r.Delete(ctx, challenge); deleteErr != nil { - log.Error(deleteErr, "Failed to delete Challenge") - return ctrl.Result{}, deleteErr - } - - return ctrl.Result{}, err -} - -// SetupWithManager sets up the controller with the Manager. -func (r *ChallengeReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&hexactfproj.Challenge{}). - Owns(&appsv1.Deployment{}). - Owns(&corev1.Service{}). - Named("challenge"). - Complete(r) -} diff --git a/internal/controller/v1/challenge_controller_test.go b/internal/controller/v1/challenge_controller_test.go deleted file mode 100644 index 2d8dc79..0000000 --- a/internal/controller/v1/challenge_controller_test.go +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -// import ( -// "context" - -// . "github.com/onsi/ginkgo/v2" -// . "github.com/onsi/gomega" -// "k8s.io/apimachinery/pkg/api/errors" -// "k8s.io/apimachinery/pkg/types" -// "sigs.k8s.io/controller-runtime/pkg/reconcile" - -// metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - -// appsv1alpha1 "github.com/hexactf/challenge-operator/api/v1alpha1" -// ) - -// var _ = Describe("Challenge Controller", func() { -// Context("When reconciling a resource", func() { -// const resourceName = "test-resource" - -// ctx := context.Background() - -// typeNamespacedName := types.NamespacedName{ -// Name: resourceName, -// Namespace: "default", // TODO(user):Modify as needed -// } -// challenge := &appsv1alpha1.Challenge{} - -// BeforeEach(func() { -// By("creating the custom resource for the Kind Challenge") -// err := k8sClient.Get(ctx, typeNamespacedName, challenge) -// if err != nil && errors.IsNotFound(err) { -// resource := &appsv1alpha1.Challenge{ -// ObjectMeta: metav1.ObjectMeta{ -// Name: resourceName, -// Namespace: "default", -// }, -// // TODO(user): Specify other spec details if needed. -// } -// Expect(k8sClient.Create(ctx, resource)).To(Succeed()) -// } -// }) - -// AfterEach(func() { -// // TODO(user): Cleanup logic after each test, like removing the resource instance. -// resource := &appsv1alpha1.Challenge{} -// err := k8sClient.Get(ctx, typeNamespacedName, resource) -// Expect(err).NotTo(HaveOccurred()) - -// By("Cleanup the specific resource instance Challenge") -// Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) -// }) -// It("should successfully reconcile the resource", func() { -// By("Reconciling the created resource") -// controllerReconciler := &ChallengeReconciler{ -// Client: k8sClient, -// Scheme: k8sClient.Scheme(), -// } - -// _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ -// NamespacedName: typeNamespacedName, -// }) -// Expect(err).NotTo(HaveOccurred()) -// // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. -// // Example: If you expect a certain status condition after reconciliation, verify it here. -// }) -// }) -// }) diff --git a/internal/controller/v1/challenge_definition.go b/internal/controller/v1/challenge_definition.go deleted file mode 100644 index cc59608..0000000 --- a/internal/controller/v1/challenge_definition.go +++ /dev/null @@ -1,252 +0,0 @@ -package controller - -import ( - "context" - "fmt" - - hexactfproj "github.com/hexactf/challenge-operator/api/v1alpha1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func (r *ChallengeReconciler) loadChallengeDefinition(ctx context.Context, req ctrl.Request, challenge *hexactfproj.Challenge) error { - // Fetch the challenge definition - definition, err := r.getChallengeDefinition(ctx, challenge) - if err != nil { - log.Error(err, "Failed to get ChallengeDefinition", "definition", challenge.Spec.Definition) - return err - } - - // challenge.Status.IsOne = definition.Spec.IsOne - - // if err := r.Status().Update(ctx, challenge); err != nil { - // log.Error(err, "Failed to update Challenge status") - // return err - // } - - // if err := r.Get(ctx, req.NamespacedName, challenge); err != nil { - // return err - // } - - for _, component := range definition.Spec.Components { - identifier := NewChallengeIdentifier(challenge, component) - - if err = r.loadDeployment(ctx, challenge, component, identifier); err != nil { - log.Error(err, "Failed to load Deployment", "component", component) - return err - } - - if err = r.loadService(ctx, challenge, component, identifier); err != nil { - log.Error(err, "Failed to load Service", "component", component) - return err - } - } - - // Send Kafka message - user, challengeID := challenge.Labels["apps.hexactf.io/user"], challenge.Labels["apps.hexactf.io/challengeId"] - if err = r.KafkaClient.SendStatusChange(user, challengeID, "Creating"); err != nil { - log.Error(err, "Failed to send status change message", "user", user, "challengeID", challengeID) - return err - } - - return nil -} - -// func (r *ChallengeReconciler) loadChallengeDefinition(ctx context.Context, challenge *hexactfproj.Challenge) error { -// definition, err := r.getChallengeDefinition(ctx, challenge) -// if err != nil { -// log.Error(err, "failed to get ChallengeDefinition %s", challenge.Spec.Definition) -// return err - -// } - -// challenge = &hexactfproj.Challenge{} -// if err := r.Get(ctx, client.ObjectKey{Name: challenge.Name, Namespace: challenge.Namespace}, challenge); err != nil { -// log.Error(err, "failed to get latest Challenge") -// return err -// } -// // IsOne 설정 -// if !definition.Spec.IsOne { -// definition.Spec.IsOne = false -// } -// challenge.Status.IsOne = definition.Spec.IsOne -// // Update the status in the cluster -// if err := r.Status().Update(ctx, challenge); err != nil { -// log.Error(err, "failed to update Challenge status") -// return err -// } - -// for _, component := range definition.Spec.Components { -// identifier := NewChallengeIdentifier(challenge, component) - -// err = r.loadDeployment(ctx, challenge, component, identifier) -// if err != nil { -// log.Error(err, "failed to load Deployment") -// return err -// } - -// err = r.loadService(ctx, challenge, component, identifier) -// if err != nil { -// log.Error(err, "failed to load Service") -// return err -// } -// } - -// // 메세지 전송 -// err = r.KafkaClient.SendStatusChange(challenge.Labels["apps.hexactf.io/user"], challenge.Labels["apps.hexactf.io/challengeId"], "Creating") -// if err != nil { -// log.Error(err, "Failed to send status change message") -// return err -// } - -// return nil -// } - -// GetChallengeDefinition ChallengeDefinition 리소스를 로드 -func (r *ChallengeReconciler) getChallengeDefinition(ctx context.Context, challenge *hexactfproj.Challenge) (*hexactfproj.ChallengeDefinition, error) { - var definition hexactfproj.ChallengeDefinition - if err := r.Get(ctx, client.ObjectKey{ - Namespace: challenge.Namespace, - Name: challenge.Spec.Definition, - }, &definition); err != nil { - log.Error(err, "failed to get ChallengeDefinition") - return nil, fmt.Errorf("failed to load definition %s: %w", challenge.Spec.Definition, err) - } - return &definition, nil -} - -func (r *ChallengeReconciler) loadDeployment(ctx context.Context, challenge *hexactfproj.Challenge, component hexactfproj.Component, identifier *ChallengeIdentifier) error { - - deploy := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: identifier.GetDeploymentPrefix(), - Namespace: challenge.Namespace, - Labels: identifier.GetLabels(), - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &component.Deployment.Spec.Replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: identifier.GetSelector(), - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: identifier.GetLabels(), - }, - Spec: corev1.PodSpec{ - NodeSelector: component.Deployment.Spec.Template.Spec.NodeSelector, - Containers: component.Deployment.Spec.Template.Spec.Containers, - }, - }, - }, - } - - // Deployment가 존재하는지 확인 - err := r.Get(ctx, client.ObjectKey{ - Namespace: challenge.Namespace, - Name: deploy.Name, - }, deploy) - - if err != nil { - if errors.IsNotFound(err) { - log.Info("Creating Deployment", "Deployment.Namespace", deploy.Namespace, "Deployment.Name", deploy.Name) - - // Owner Reference 설정 - if err := ctrl.SetControllerReference(challenge, deploy, r.Scheme); err != nil { - log.Error(err, "failed to set controller reference") - return err - } - - err = r.Client.Create(ctx, deploy) - if err != nil { - log.Error(err, "failed to create Deployment") - return err - } - } else { - log.Error(err, "failed to get Deployment") - return err - } - } - - return nil -} - -// LoadService Service 리소스 생성 -func (r *ChallengeReconciler) loadService(ctx context.Context, challenge *hexactfproj.Challenge, - component hexactfproj.Component, identifier *ChallengeIdentifier) error { - - log.Info("Loading service", - "challenge", challenge.Name, - "component", component.Name, - "prefix", identifier.GetServicePrefix()) - - // Service가 nil이면 처리하지 않음 - if component.Service == nil { - log.Info("No service defined for component", - "component", component.Name) - return nil - } - - // 새로운 Service 객체 생성 - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: identifier.GetServicePrefix(), - Namespace: challenge.Namespace, - Labels: identifier.GetLabels(), - }, - Spec: corev1.ServiceSpec{ - Selector: identifier.GetSelector(), - Ports: component.Service.Spec.Ports, - Type: component.Service.Spec.Type, - }, - } - - // Service가 존재하는지 확인 - err := r.Get(ctx, types.NamespacedName{ - Name: identifier.GetServicePrefix(), - Namespace: challenge.Namespace, - }, service) - - if err != nil { - if errors.IsNotFound(err) { - log.Info("Creating new service", - "name", identifier.GetServicePrefix(), - "namespace", challenge.Namespace) - - // Owner Reference 설정 - if err := ctrl.SetControllerReference(challenge, service, r.Scheme); err != nil { - log.Error(err, "Failed to set controller reference for Service") - return err - } - - if err := r.Create(ctx, service); err != nil { - log.Error(err, "Failed to create Service") - return err - } - - log.Info("Successfully created service", - "name", identifier.GetServicePrefix()) - - if service.Spec.Type == corev1.ServiceTypeNodePort { - challenge.Status.Endpoint = int(service.Spec.Ports[0].NodePort) - log.Info("NodePort created", - "port", service.Spec.Ports[0].NodePort) - } - - if err := r.Status().Update(ctx, challenge); err != nil { - log.Error(err, "Failed to update Challenge status with NodePort information") - return err - } - - return nil - } - log.Error(err, "Failed to get Service") - return err - } - - return nil -} diff --git a/internal/controller/v1/custom_metrics.go b/internal/controller/v1/custom_metrics.go deleted file mode 100644 index 060d881..0000000 --- a/internal/controller/v1/custom_metrics.go +++ /dev/null @@ -1,21 +0,0 @@ -package controller - -import ( - "github.com/prometheus/client_golang/prometheus" - "sigs.k8s.io/controller-runtime/pkg/metrics" -) - -var ( - crStatusMetric = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "challenge_resource_status", - Help: "Tracks the status of the custom resource", - }, - []string{"challeng_id", "challenge_name", "username", "namespace"}, - ) -) - -func init() { - metrics.Registry.MustRegister(crStatusMetric) - -} diff --git a/internal/controller/v1/finalizer.go b/internal/controller/v1/finalizer.go deleted file mode 100644 index 024557a..0000000 --- a/internal/controller/v1/finalizer.go +++ /dev/null @@ -1,82 +0,0 @@ -package controller - -import ( - "context" - "fmt" - "time" - - hexactfproj "github.com/hexactf/challenge-operator/api/v1alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" -) - -const ( - challengeFinalizer = "challenge.hexactf.io/finalizer" -) - -func (r *ChallengeReconciler) addFinalizer(ctx context.Context, challenge *hexactfproj.Challenge) (ctrl.Result, error) { - challenge.Finalizers = append(challenge.Finalizers, challengeFinalizer) - if err := r.Update(ctx, challenge); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to add finalizer: %w", err) - } - return ctrl.Result{}, nil -} - -func (r *ChallengeReconciler) removeFinalizer(ctx context.Context, challenge *hexactfproj.Challenge) error { - retries := 3 - - for i := 0; i < retries; i++ { - var latestChallenge hexactfproj.Challenge - if err := r.Get(ctx, types.NamespacedName{ - Namespace: challenge.Namespace, - Name: challenge.Name, - }, &latestChallenge); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return err - } - - latestChallenge.Finalizers = removeString(latestChallenge.Finalizers) - - if err := r.Update(ctx, &latestChallenge); err != nil { - if apierrors.IsConflict(err) { - time.Sleep(time.Millisecond * 100 * time.Duration(i+1)) - continue - } - return err - } - - return nil - } - - return fmt.Errorf("failed to remove finalizer after %d attempts", retries) -} - -func containsString(slice []string) bool { - for _, item := range slice { - if item == challengeFinalizer { - return true - } - } - return false -} - -func removeString(slice []string) []string { - if len(slice) == 0 { - return nil - } - - var result []string - for _, item := range slice { - if item != challengeFinalizer { - result = append(result, item) - } - } - - if len(result) == 0 { - return nil - } - return result -} diff --git a/internal/controller/v1/finalizer_test.go b/internal/controller/v1/finalizer_test.go deleted file mode 100644 index 1e4dea7..0000000 --- a/internal/controller/v1/finalizer_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package controller - -import ( - "context" - "testing" - - hexactfproj "github.com/hexactf/challenge-operator/api/v1alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func TestFinalizerOperations(t *testing.T) { - // 스키마 설정 - s := runtime.NewScheme() - _ = scheme.AddToScheme(s) - _ = hexactfproj.AddToScheme(s) - - // 테스트용 Challenge 생성 헬퍼 함수 - createTestChallenge := func(name string, finalizers []string) *hexactfproj.Challenge { - return &hexactfproj.Challenge{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: "default", - Finalizers: finalizers, - }, - } - } - - t.Run("addFinalizer", func(t *testing.T) { - tests := []struct { - name string - challenge *hexactfproj.Challenge - wantErr bool - }{ - { - name: "finalizer 추가 성공", - challenge: createTestChallenge("test-1", nil), - wantErr: false, - }, - { - name: "이미 finalizer가 있는 경우", - challenge: createTestChallenge("test-2", []string{challengeFinalizer}), - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // fake client 생성 - client := fake.NewClientBuilder(). - WithScheme(s). - WithObjects(tt.challenge). - Build() - - r := &ChallengeReconciler{Client: client} - - // addFinalizer 실행 - _, err := r.addFinalizer(context.Background(), tt.challenge) - - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - // Challenge 다시 가져와서 finalizer 확인 - updated := &hexactfproj.Challenge{} - err = client.Get(context.Background(), - types.NamespacedName{ - Name: tt.challenge.Name, - Namespace: tt.challenge.Namespace, - }, - updated) - require.NoError(t, err) - assert.Contains(t, updated.Finalizers, challengeFinalizer) - }) - } - }) - - t.Run("removeFinalizer", func(t *testing.T) { - tests := []struct { - name string - challenge *hexactfproj.Challenge - wantErr bool - }{ - { - name: "finalizer 제거 성공", - challenge: createTestChallenge("test-3", []string{challengeFinalizer}), - wantErr: false, - }, - { - name: "finalizer가 없는 경우", - challenge: createTestChallenge("test-4", nil), - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // fake client 생성 - client := fake.NewClientBuilder(). - WithScheme(s). - WithObjects(tt.challenge). - Build() - - r := &ChallengeReconciler{Client: client} - - // removeFinalizer 실행 - err := r.removeFinalizer(context.Background(), tt.challenge) - - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - // Challenge가 존재하는 경우 finalizer 확인 - updated := &hexactfproj.Challenge{} - err = client.Get(context.Background(), - types.NamespacedName{ - Name: tt.challenge.Name, - Namespace: tt.challenge.Namespace, - }, - updated) - - if err == nil { - assert.NotContains(t, updated.Finalizers, challengeFinalizer) - } - }) - } - }) -} - -func TestFinalizerHelperFunctions(t *testing.T) { - t.Run("containsString", func(t *testing.T) { - tests := []struct { - name string - slice []string - expected bool - }{ - { - name: "빈 슬라이스", - slice: []string{}, - expected: false, - }, - { - name: "finalizer 포함", - slice: []string{challengeFinalizer, "other-finalizer"}, - expected: true, - }, - { - name: "finalizer 미포함", - slice: []string{"other-finalizer"}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := containsString(tt.slice) - assert.Equal(t, tt.expected, result) - }) - } - }) - - t.Run("removeString", func(t *testing.T) { - tests := []struct { - name string - slice []string - expected []string - }{ - { - name: "빈 슬라이스", - slice: []string{}, - expected: nil, - }, - { - name: "finalizer만 있는 경우", - slice: []string{challengeFinalizer}, - expected: nil, - }, - { - name: "여러 finalizer가 있는 경우", - slice: []string{challengeFinalizer, "other-finalizer"}, - expected: []string{"other-finalizer"}, - }, - { - name: "다른 finalizer만 있는 경우", - slice: []string{"other-finalizer"}, - expected: []string{"other-finalizer"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := removeString(tt.slice) - assert.Equal(t, tt.expected, result) - }) - } - }) -} diff --git a/internal/controller/v1/identifier.go b/internal/controller/v1/identifier.go deleted file mode 100644 index 263b09e..0000000 --- a/internal/controller/v1/identifier.go +++ /dev/null @@ -1,56 +0,0 @@ -package controller - -import ( - "fmt" - - hexactfproj "github.com/hexactf/challenge-operator/api/v1alpha1" -) - -// ChallengeIdentifier -// 도메인에 맞는 식별자를 생성해주는 구조체 -type ChallengeIdentifier struct { - prefix string - labels map[string]string -} - -func NewChallengeIdentifier(challenge *hexactfproj.Challenge, component hexactfproj.Component) *ChallengeIdentifier { - // prefix 생성 (리소스 이름에 사용) - prefix := fmt.Sprintf("chall-%s-%s-%s", - challenge.Labels["apps.hexactf.io/challengeId"], - component.Name, - challenge.Labels["apps.hexactf.io/user"]) - - // 단일 레이블 맵 사용 - labels := map[string]string{ - "apps.hexactf.io/instance": prefix, - "apps.hexactf.io/name": component.Name, - "apps.hexactf.io/part-of": challenge.Name, - "apps.hexactf.io/managed-by": "challenge-operator", - "apps.hexactf.io/deployment": prefix + "-deploy", - "apps.hexactf.io/service": prefix + "-svc", - } - - return &ChallengeIdentifier{ - prefix: prefix, - labels: labels, - } -} - -func (c *ChallengeIdentifier) GetLabels() map[string]string { - return c.labels -} - -// Selector 메서드 추가 - Service와 Deployment의 레이블 매칭에 사용 -func (c *ChallengeIdentifier) GetSelector() map[string]string { - return map[string]string{ - "apps.hexactf.io/instance": c.prefix, - } -} - -func (c *ChallengeIdentifier) GetDeploymentPrefix() string { - return c.prefix + "-deploy" -} - -func (c *ChallengeIdentifier) GetServicePrefix() string { - return c.prefix + "-svc" -} diff --git a/internal/controller/v1/identifier_test.go b/internal/controller/v1/identifier_test.go deleted file mode 100644 index babed49..0000000 --- a/internal/controller/v1/identifier_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package controller - -import ( - "testing" - - hexactfproj "github.com/hexactf/challenge-operator/api/v1alpha1" - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestNewChallengeIdentifier(t *testing.T) { - // 테스트 데이터 준비 - challenge := &hexactfproj.Challenge{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-challenge", - Labels: map[string]string{ - "apps.hexactf.io/challengeId": "test-id", - "apps.hexactf.io/user": "test-user", - }, - }, - } - - component := hexactfproj.Component{ - Name: "ubuntu", - } - - // 테스트 실행 - identifier := NewChallengeIdentifier(challenge, component) - - // 검증 - t.Run("prefix 포맷 검증", func(t *testing.T) { - expectedPrefix := "chall-test-id-ubuntu-test-user" - assert.Equal(t, expectedPrefix, identifier.prefix) - }) - - t.Run("labels 검증", func(t *testing.T) { - expectedLabels := map[string]string{ - "apps.hexactf.io/instance": "chall-test-id-ubuntu-test-user", - "apps.hexactf.io/name": "ubuntu", - "apps.hexactf.io/part-of": "test-challenge", - "apps.hexactf.io/managed-by": "challenge-operator", - } - assert.Equal(t, expectedLabels, identifier.GetLabels()) - }) - - t.Run("selector 검증", func(t *testing.T) { - expectedSelector := map[string]string{ - "apps.hexactf.io/instance": "chall-test-id-ubuntu-test-user", - } - assert.Equal(t, expectedSelector, identifier.GetSelector()) - }) - - t.Run("deployment prefix 검증", func(t *testing.T) { - expectedDeployPrefix := "chall-test-id-ubuntu-test-user-deploy" - assert.Equal(t, expectedDeployPrefix, identifier.GetDeploymentPrefix()) - }) - - t.Run("service prefix 검증", func(t *testing.T) { - expectedSvcPrefix := "chall-test-id-ubuntu-test-user-svc" - assert.Equal(t, expectedSvcPrefix, identifier.GetServicePrefix()) - }) -} - -func TestNewChallengeIdentifierEdgeCases(t *testing.T) { - tests := []struct { - name string - challenge *hexactfproj.Challenge - component hexactfproj.Component - wantPrefix string - }{ - { - name: "빈 레이블", - challenge: &hexactfproj.Challenge{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-challenge", - Labels: map[string]string{}, - }, - }, - component: hexactfproj.Component{ - Name: "ubuntu", - }, - wantPrefix: "chall--ubuntu-", - }, - { - name: "긴 이름", - challenge: &hexactfproj.Challenge{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-challenge", - Labels: map[string]string{ - "apps.hexactf.io/challengeId": "very-long-challenge-id", - "apps.hexactf.io/user": "very-long-user-name", - }, - }, - }, - component: hexactfproj.Component{ - Name: "very-long-component-name", - }, - wantPrefix: "chall-very-long-challenge-id-very-long-component-name-very-long-user-name", - }, - { - name: "특수문자 포함", - challenge: &hexactfproj.Challenge{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-challenge", - Labels: map[string]string{ - "apps.hexactf.io/challengeId": "test@id", - "apps.hexactf.io/user": "user#1", - }, - }, - }, - component: hexactfproj.Component{ - Name: "test!comp", - }, - wantPrefix: "chall-test@id-test!comp-user#1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - identifier := NewChallengeIdentifier(tt.challenge, tt.component) - - // prefix 검증 - assert.Equal(t, tt.wantPrefix, identifier.prefix) - - // 기본 레이블 존재 여부 검증 - labels := identifier.GetLabels() - assert.Contains(t, labels, "apps.hexactf.io/managed-by") - assert.Equal(t, "challenge-operator", labels["apps.hexactf.io/managed-by"]) - - // selector가 prefix를 포함하는지 검증 - selector := identifier.GetSelector() - assert.Equal(t, tt.wantPrefix, selector["apps.hexactf.io/instance"]) - }) - } -} diff --git a/internal/controller/v1/kafka.go b/internal/controller/v1/kafka.go deleted file mode 100644 index a43441a..0000000 --- a/internal/controller/v1/kafka.go +++ /dev/null @@ -1,101 +0,0 @@ -package controller - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/IBM/sarama" -) - -const ( - kafkaTopic = "challenge-status" -) - -// KafkaProducer Kafka producer -type KafkaProducer struct { - producer sarama.SyncProducer -} - -// StatusMessage 는 Kafka에 보낼 메세지 -type StatusMessage struct { - User string `json:"user"` - ProblemID string `json:"problemId"` - NewStatus string `json:"newStatus"` - Timestamp time.Time `json:"timestamp"` -} - -// NewKafkaProducer Kafka producer 객체 생성 -// Producer 관련 설정 수행 -func NewKafkaProducer(brokers []string) (*KafkaProducer, error) { - config := sarama.NewConfig() - config.Producer.RequiredAcks = sarama.WaitForAll - config.Producer.Retry.Max = 5 - config.Producer.Return.Successes = true - - var producer sarama.SyncProducer - var err error - maxRetries := 10 - retryInterval := time.Second * 10 - - for i := 0; i < maxRetries; i++ { - producer, err = sarama.NewSyncProducer(brokers, config) - if err == nil { - break - } - //log.Info("Failed to connect to Kafka, retrying...", - // "attempt", i+1, - // "maxRetries", maxRetries, - // "error", err) - time.Sleep(retryInterval) - } - if err != nil { - return nil, fmt.Errorf("failed to create Kafka producer: %w", err) - } - - return &KafkaProducer{ - producer: producer, - }, nil -} - -// SendStatusChange 상태 메세지를 보낼때 사용된다. -func (k *KafkaProducer) SendStatusChange(user, problemId, newStatus string) error { - - if k == nil { - return fmt.Errorf("KafkaProducer instance is nil") - } - if k.producer == nil { - return fmt.Errorf("internal Kafka producer is nil") - } - msg := StatusMessage{ - User: user, - ProblemID: problemId, - NewStatus: newStatus, - Timestamp: time.Now(), - } - - payload, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal status message: %w", err) - } - - _, _, err = k.producer.SendMessage(&sarama.ProducerMessage{ - Topic: kafkaTopic, - Value: sarama.StringEncoder(payload), - Key: sarama.StringEncoder(fmt.Sprintf("%s-%s", user, problemId)), - }) - - if err != nil { - return fmt.Errorf("failed to send Kafka message: %w", err) - } - - return nil -} - -// Close Kafka producer 종료 -func (k *KafkaProducer) Close() error { - if k.producer != nil { - return k.producer.Close() - } - return nil -} diff --git a/internal/controller/v1/kafka_test.go b/internal/controller/v1/kafka_test.go deleted file mode 100644 index 1224c9a..0000000 --- a/internal/controller/v1/kafka_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package controller - -import ( - "encoding/json" - "testing" - "time" - - "github.com/IBM/sarama" - "github.com/IBM/sarama/mocks" - "github.com/stretchr/testify/assert" -) - -func TestKafkaProducer_SendStatusChange(t *testing.T) { - tests := []struct { - name string - user string - problemId string - newStatus string - wantErr bool - }{ - { - name: "successful message send", - user: "testuser", - problemId: "problem1", - newStatus: "Created", - wantErr: false, - }, - { - name: "empty fields", - user: "", - problemId: "", - newStatus: "", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock producer - mockProducer := mocks.NewSyncProducer(t, nil) - defer mockProducer.Close() - - // Create Kafka producer with mock - kafkaProducer := &KafkaProducer{ - producer: mockProducer, - } - - // Set up expectations - mockProducer.ExpectSendMessageAndSucceed() - - // Send message - err := kafkaProducer.SendStatusChange(tt.user, tt.problemId, tt.newStatus) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - } - - // Ensure all expectations were met - assert.NoError(t, mockProducer.Close()) - }) - } -} - -func checkKafkaMessage(t *testing.T, msg *sarama.ProducerMessage, userId, problemId, newStatus string) { - // Check topic - assert.Equal(t, kafkaTopic, msg.Topic) - - // Check key - key, err := msg.Key.Encode() - assert.NoError(t, err) - assert.Equal(t, userId+"-"+problemId, string(key)) - - // Check value - value, err := msg.Value.Encode() - assert.NoError(t, err) - - var statusMsg StatusMessage - err = json.Unmarshal(value, &statusMsg) - assert.NoError(t, err) - - // Verify message contents - assert.Equal(t, userId, statusMsg.User) - assert.Equal(t, problemId, statusMsg.ProblemID) - assert.Equal(t, newStatus, statusMsg.NewStatus) - assert.False(t, statusMsg.Timestamp.IsZero()) - assert.True(t, statusMsg.Timestamp.Before(time.Now())) -} - -func TestKafkaProducer_Close(t *testing.T) { - tests := []struct { - name string - producer sarama.SyncProducer - wantErr bool - }{ - { - name: "successful close", - producer: mocks.NewSyncProducer(t, nil), - wantErr: false, - }, - { - name: "nil producer", - producer: nil, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - k := &KafkaProducer{ - producer: tt.producer, - } - - err := k.Close() - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestNewKafkaProducer(t *testing.T) { - tests := []struct { - name string - brokers []string - wantErr bool - }{ - { - name: "empty brokers", - brokers: []string{}, - wantErr: true, - }, - { - name: "invalid broker address", - brokers: []string{"invalid:9092"}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - producer, err := NewKafkaProducer(tt.brokers) - if tt.wantErr { - assert.Error(t, err) - assert.Nil(t, producer) - } else { - assert.NoError(t, err) - assert.NotNil(t, producer) - assert.NoError(t, producer.Close()) - } - }) - } -} - -func TestKafkaProducer_SendStatusChange_ProducerError(t *testing.T) { - // Create mock producer that expects a message and returns an error - mockProducer := mocks.NewSyncProducer(t, nil) - defer mockProducer.Close() - - // Set up the mock to return an error - mockProducer.ExpectSendMessageAndFail(sarama.ErrBrokerNotAvailable) - - kafkaProducer := &KafkaProducer{ - producer: mockProducer, - } - - // Attempt to send a message - err := kafkaProducer.SendStatusChange("user1", "problem1", "Created") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to send Kafka message") - - // Ensure all expectations were met - assert.NoError(t, mockProducer.Close()) -} diff --git a/internal/controller/v1/suite_test.go b/internal/controller/v1/suite_test.go deleted file mode 100644 index 3cd598c..0000000 --- a/internal/controller/v1/suite_test.go +++ /dev/null @@ -1,100 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "fmt" - "path/filepath" - "runtime" - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - - appsv1alpha1 "github.com/hexactf/challenge-operator/api/v1alpha1" - appsv2alpha1 "github.com/hexactf/challenge-operator/api/v2alpha1" - // +kubebuilder:scaffold:imports -) - -// These tests use Ginkgo (BDD-style Go testing framework). Refer to -// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. - -var cfg *rest.Config -var k8sClient client.Client -var testEnv *envtest.Environment -var ctx context.Context -var cancel context.CancelFunc - -func TestControllers(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "Controller Suite") -} - -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) - - ctx, cancel = context.WithCancel(context.TODO()) - - By("bootstrapping test environment") - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, - - // The BinaryAssetsDirectory is only required if you want to run the tests directly - // without call the makefile target test. If not informed it will look for the - // default path defined in controller-runtime which is /usr/local/kubebuilder/. - // Note that you must have the required binaries setup under the bin directory to perform - // the tests directly. When we run make test it will be setup and used automatically. - BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", - fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)), - } - - var err error - // cfg is defined in this file globally. - cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) - - err = appsv1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - err = appsv2alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - // +kubebuilder:scaffold:scheme - - k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient).NotTo(BeNil()) - -}) - -var _ = AfterSuite(func() { - By("tearing down the test environment") - cancel() - err := testEnv.Stop() - Expect(err).NotTo(HaveOccurred()) -}) diff --git a/internal/controller/v2/benchmark_test.go b/internal/controller/v2/benchmark_test.go new file mode 100644 index 0000000..6bd5962 --- /dev/null +++ b/internal/controller/v2/benchmark_test.go @@ -0,0 +1,263 @@ +package controller + +import ( + "context" + "testing" + + hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func BenchmarkChallengeReconciliation(b *testing.B) { + // Setup + scheme := runtime.NewScheme() + _ = hexactfproj.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + reconciler := &ChallengeReconciler{ + Client: client, + Scheme: scheme, + } + ctx := context.Background() + + // Create definition + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "benchmark-definition", + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "benchmark-container", + Image: "nginx:latest", + }, + }, + }, + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "benchmark-service", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + }, + }, + }, + } + _ = client.Create(ctx, definition) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + // Create challenge + challenge := NewChallengeBuilder(). + WithName("benchmark-challenge"). + WithNamespace("default"). + WithFinalizer("challenge.hexactf.io/finalizer"). + WithPodName("benchmark-pod"). + Build() + challenge.Spec.Definition = "benchmark-definition" + + _ = client.Create(ctx, challenge) + + // Create pod + pod := NewPodBuilder(). + WithName("benchmark-pod"). + WithNamespace("default"). + WithPhase(corev1.PodRunning). + Build() + _ = client.Create(ctx, pod) + + // Reconcile + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "benchmark-challenge", + Namespace: "default", + }, + } + _, _ = reconciler.Reconcile(ctx, req) + + // Cleanup + _ = client.Delete(ctx, challenge) + _ = client.Delete(ctx, pod) + } +} + +func BenchmarkLoadChallengeDefinition(b *testing.B) { + // Setup + scheme := runtime.NewScheme() + _ = hexactfproj.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + reconciler := &ChallengeReconciler{ + Client: client, + Scheme: scheme, + } + ctx := context.Background() + + // Create definition + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "benchmark-definition", + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "benchmark-container", + Image: "nginx:latest", + }, + }, + }, + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "benchmark-service", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + }, + }, + }, + } + _ = client.Create(ctx, definition) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + challenge := NewChallengeBuilder(). + WithName("benchmark-challenge"). + WithNamespace("default"). + Build() + challenge.Spec.Definition = "benchmark-definition" + + _ = client.Create(ctx, challenge) + + // Get the challenge from client to ensure it exists + _ = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + + _ = reconciler.loadChallengeDefinition(ctx, ctrl.Request{}, challenge) + + // Cleanup + _ = client.Delete(ctx, challenge) + } +} + +func BenchmarkHandlePendingState(b *testing.B) { + // Setup + scheme := runtime.NewScheme() + _ = hexactfproj.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + reconciler := &ChallengeReconciler{ + Client: client, + Scheme: scheme, + } + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + // Create challenge with pending status + challenge := NewChallengeBuilder(). + WithName("benchmark-challenge"). + WithNamespace("default"). + WithPodName("benchmark-pod"). + Build() + + _ = client.Create(ctx, challenge) + + // Create pod + pod := NewPodBuilder(). + WithName("benchmark-pod"). + WithNamespace("default"). + WithPhase(corev1.PodRunning). + Build() + _ = client.Create(ctx, pod) + + // Get the challenge from client to ensure it exists + _ = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + + _, _ = reconciler.handlePendingState(ctx, challenge) + + // Cleanup + _ = client.Delete(ctx, challenge) + _ = client.Delete(ctx, pod) + } +} + +func BenchmarkHandleRunningState(b *testing.B) { + // Setup + scheme := runtime.NewScheme() + _ = hexactfproj.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + reconciler := &ChallengeReconciler{ + Client: client, + Scheme: scheme, + } + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + // Create challenge with running status + status := hexactfproj.NewCurrentStatus() + status.Running() + + challenge := NewChallengeBuilder(). + WithName("benchmark-challenge"). + WithNamespace("default"). + Build() + challenge.Status.CurrentStatus = *status + + _ = client.Create(ctx, challenge) + + // Get the challenge from client to ensure it exists + _ = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + + _, _ = reconciler.handleRunningState(ctx, challenge) + + // Cleanup + _ = client.Delete(ctx, challenge) + } +} diff --git a/internal/controller/v2/challenge_controller.go b/internal/controller/v2/challenge_controller.go index ad4ed46..22f0fe3 100644 --- a/internal/controller/v2/challenge_controller.go +++ b/internal/controller/v2/challenge_controller.go @@ -44,10 +44,6 @@ var log = logr.Log.WithName("ChallengeController") type ChallengeReconciler struct { client.Client Scheme *runtime.Scheme - - // KafkaClient is the Kafka producer client - // 중요한 메세지를 Kafka를 통해 보낸다. - // KafkaClient *kafka.KafkaProducer } // +kubebuilder:rbac:groups=apps.hexactf.io,resources=challenges,verbs=get;list;watch;create;update;patch;delete diff --git a/internal/controller/v2/challenge_controller_test.go b/internal/controller/v2/challenge_controller_test.go new file mode 100644 index 0000000..d4a840f --- /dev/null +++ b/internal/controller/v2/challenge_controller_test.go @@ -0,0 +1,285 @@ +package controller + +import ( + "context" + "time" + + hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("Challenge Controller", func() { + var ( + reconciler *ChallengeReconciler + client client.Client + ctx context.Context + req ctrl.Request + ) + + BeforeEach(func() { + ctx = context.Background() + client = CreateFakeClient() + reconciler = &ChallengeReconciler{ + Client: client, + Scheme: client.Scheme(), + } + req = ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "test-challenge", + Namespace: "default", + }, + } + }) + + Describe("Reconcile", func() { + Context("when challenge is not found", func() { + It("should return empty result", func() { + // When + result, err := reconciler.Reconcile(ctx, req) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Context("when challenge exists", func() { + It("should handle challenge with deletion timestamp", func() { + // Given + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + } + + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // When + result, err := reconciler.Reconcile(ctx, req) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("should add finalizer if not present", func() { + // Given + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + }, + } + + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // When + result, err := reconciler.Reconcile(ctx, req) + + // Then + Expect(err).NotTo(HaveOccurred()) + // Should return empty result since initialization will fail without definition + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("should initialize challenge if not started", func() { + // Given + // Create ChallengeDefinition first + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-definition", + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "test-container", + Image: "nginx:latest", + }, + }, + }, + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + }, + }, + }, + } + + err := client.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + Finalizers: []string{challengeFinalizer}, + }, + Spec: hexactfproj.ChallengeSpec{ + Definition: "test-definition", + }, + } + + err = client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // When + result, err := reconciler.Reconcile(ctx, req) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{RequeueAfter: requeueInterval})) + }) + + It("should handle pending state", func() { + // Given + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + Finalizers: []string{challengeFinalizer}, + Labels: map[string]string{ + "apps.hexactf.io/podName": "test-pod", + }, + }, + Status: hexactfproj.ChallengeStatus{ + StartedAt: &metav1.Time{Time: time.Now()}, + CurrentStatus: *hexactfproj.NewCurrentStatus(), + }, + } + + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // When + result, err := reconciler.Reconcile(ctx, req) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("should handle running state", func() { + // Given + status := hexactfproj.NewCurrentStatus() + status.Running() + + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + Finalizers: []string{challengeFinalizer}, + }, + Status: hexactfproj.ChallengeStatus{ + StartedAt: &metav1.Time{Time: time.Now()}, + CurrentStatus: *status, + }, + } + + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // When + result, err := reconciler.Reconcile(ctx, req) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{RequeueAfter: requeueInterval})) + }) + }) + }) +}) + +var _ = Describe("Challenge Controller Performance", func() { + It("should reconcile efficiently", func() { + // Given + client := CreateFakeClient() + reconciler := &ChallengeReconciler{ + Client: client, + Scheme: client.Scheme(), + } + ctx := context.Background() + + // Create ChallengeDefinition first + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "performance-definition", + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "performance-container", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } + + err := client.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + challenge := NewChallengeBuilder(). + WithName("performance-challenge"). + WithNamespace("default"). + WithFinalizer("challenge.hexactf.io/finalizer"). + WithPodName("performance-pod"). + Build() + + // Set the definition reference + challenge.Spec.Definition = "performance-definition" + + err = client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // Create a pod that the challenge references + pod := NewPodBuilder(). + WithName("performance-pod"). + WithNamespace("default"). + WithPhase(corev1.PodRunning). + Build() + + err = client.Create(ctx, pod) + Expect(err).NotTo(HaveOccurred()) + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "performance-challenge", + Namespace: "default", + }, + } + + // When + result, err := reconciler.Reconcile(ctx, req) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{RequeueAfter: requeueInterval})) + }) +}) diff --git a/internal/controller/v2/challenge_definition.go b/internal/controller/v2/challenge_definition.go index 484afcf..2b7d43b 100644 --- a/internal/controller/v2/challenge_definition.go +++ b/internal/controller/v2/challenge_definition.go @@ -4,7 +4,7 @@ import ( "context" hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" - "github.com/hexactf/challenge-operator/internal/utils" + "github.com/hexactf/challenge-operator/internal/controller/v2/utils" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" diff --git a/internal/controller/v2/challenge_definition_test.go b/internal/controller/v2/challenge_definition_test.go index b0b429f..b89e92c 100644 --- a/internal/controller/v2/challenge_definition_test.go +++ b/internal/controller/v2/challenge_definition_test.go @@ -1 +1,546 @@ package controller + +import ( + "context" + "fmt" + + hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("Challenge Definition", func() { + var ( + client client.Client + reconciler *ChallengeReconciler + ctx context.Context + ) + + BeforeEach(func() { + client = CreateFakeClient() + reconciler = &ChallengeReconciler{ + Client: client, + Scheme: client.Scheme(), + } + ctx = context.Background() + }) + + Describe("LoadChallengeDefinition", func() { + Context("when challenge definition exists", func() { + It("should load challenge definition successfully", func() { + // Given + challenge := NewChallengeBuilder(). + WithName("test-challenge"). + WithNamespace("default"). + WithPodName("test-pod"). + Build() + + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-definition", + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "test-container", + Image: "nginx:latest", + }, + }, + }, + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + }, + }, + }, + } + + // Create definition in client + err := client.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + // Create challenge in client + err = client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // Get the challenge from client to ensure it exists + err = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + Expect(err).NotTo(HaveOccurred()) + + // When + err = reconciler.loadChallengeDefinition(ctx, ctrl.Request{}, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + + // Verify labels were set correctly + expectedLabels := map[string]string{ + "apps.hexactf.io/challengeId": "test-challenge-id", + "apps.hexactf.io/userId": "test-user-id", + "apps.hexactf.io/createdBy": "challenge-operator", + "apps.hexactf.io/challengeName": "challenge-test-challenge-id-test-user-id", + "apps.hexactf.io/podName": "challenge-test-challenge-id-test-user-id-pod", + "apps.hexactf.io/svcName": "challenge-test-challenge-id-test-user-id-svc", + } + + for key, expectedValue := range expectedLabels { + actualValue, exists := challenge.Labels[key] + Expect(exists).To(BeTrue(), "Expected label %s to exist", key) + Expect(actualValue).To(Equal(expectedValue), "Expected label %s to be %s, but got %s", key, expectedValue, actualValue) + } + }) + }) + + Context("when challenge definition does not exist", func() { + It("should return an error", func() { + // Given + challenge := NewChallengeBuilder(). + WithName("test-challenge"). + WithNamespace("default"). + Build() + + // Create challenge in client but not definition + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // When + err = reconciler.loadChallengeDefinition(ctx, ctrl.Request{}, challenge) + + // Then + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("LoadPod", func() { + It("should load pod successfully", func() { + // Given + challenge := NewChallengeBuilder(). + WithName("test-challenge"). + WithNamespace("default"). + WithPodName("test-pod"). + Build() + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "nginx:latest", + }, + }, + }, + } + + // When + err := reconciler.loadPod(ctx, challenge, pod) + + // Then + Expect(err).NotTo(HaveOccurred()) + + // Verify pod was created + createdPod := &corev1.Pod{} + err = client.Get(ctx, types.NamespacedName{ + Name: "test-pod", + Namespace: "default", + }, createdPod) + Expect(err).NotTo(HaveOccurred()) + + // Verify status was set to pending + Expect(challenge.Status.CurrentStatus.Status).To(Equal("Pending")) + }) + }) + + Describe("LoadService", func() { + Context("with NodePort service", func() { + It("should load service successfully", func() { + // Given + challenge := NewChallengeBuilder(). + WithName("test-challenge"). + WithNamespace("default"). + Build() + + // Set the required labels for service creation + challenge.Labels["apps.hexactf.io/svcName"] = "test-service" + + // Create challenge in client + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // Get the challenge from client to ensure it exists + err = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + Expect(err).NotTo(HaveOccurred()) + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + } + + // When + err = reconciler.loadService(ctx, challenge, service) + + // Then + Expect(err).NotTo(HaveOccurred()) + + // Verify service was created + createdService := &corev1.Service{} + err = client.Get(ctx, types.NamespacedName{ + Name: "test-service", + Namespace: "default", + }, createdService) + Expect(err).NotTo(HaveOccurred()) + + // Verify endpoint was set + Expect(challenge.Status.Endpoint).To(Equal(30080)) + }) + }) + + Context("with ClusterIP service", func() { + It("should load service successfully without setting endpoint", func() { + // Given + challenge := NewChallengeBuilder(). + WithName("test-challenge"). + WithNamespace("default"). + Build() + + // Set the required labels for service creation + challenge.Labels["apps.hexactf.io/svcName"] = "test-service" + + // Create challenge in client + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // Get the challenge from client to ensure it exists + err = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + Expect(err).NotTo(HaveOccurred()) + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Port: 80, + }, + }, + }, + } + + // When + err = reconciler.loadService(ctx, challenge, service) + + // Then + Expect(err).NotTo(HaveOccurred()) + + // Verify service was created + createdService := &corev1.Service{} + err = client.Get(ctx, types.NamespacedName{ + Name: "test-service", + Namespace: "default", + }, createdService) + Expect(err).NotTo(HaveOccurred()) + + // Verify endpoint was not set for ClusterIP + Expect(challenge.Status.Endpoint).To(Equal(0)) + }) + }) + }) + + Describe("GetChallengeDefinition", func() { + It("should get challenge definition successfully", func() { + // Given + challenge := NewChallengeBuilder(). + WithName("test-challenge"). + WithNamespace("default"). + Build() + + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-definition", + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "test-container", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } + + // Create definition in client + err := client.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + // When + retrievedDefinition, err := reconciler.getChallengeDefinition(ctx, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(retrievedDefinition).NotTo(BeNil()) + Expect(retrievedDefinition.Name).To(Equal("test-definition")) + }) + }) +}) + +var _ = Describe("Challenge Definition Performance", func() { + var ( + client client.Client + reconciler *ChallengeReconciler + ctx context.Context + ) + + BeforeEach(func() { + client = CreateFakeClient() + reconciler = &ChallengeReconciler{ + Client: client, + Scheme: client.Scheme(), + } + ctx = context.Background() + }) + + It("should load challenge definition efficiently", func() { + // Create a fresh challenge for each iteration with unique name + challengeName := fmt.Sprintf("benchmark-challenge-%d", GinkgoParallelProcess()) + definitionName := fmt.Sprintf("benchmark-definition-%d", GinkgoParallelProcess()) + challengeId := fmt.Sprintf("benchmark-challenge-id-%d", GinkgoParallelProcess()) + userId := fmt.Sprintf("benchmark-user-id-%d", GinkgoParallelProcess()) + + challenge := NewChallengeBuilder(). + WithName(challengeName). + WithNamespace("default"). + Build() + + // Set unique challengeId and userId to ensure unique pod/service names + challenge.Labels["apps.hexactf.io/challengeId"] = challengeId + challenge.Labels["apps.hexactf.io/userId"] = userId + + // Create a fresh definition for each iteration + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: definitionName, + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "benchmark-container", + Image: "nginx:latest", + }, + }, + }, + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "benchmark-service", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + }, + }, + }, + } + + // Create definition in client + err := client.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + // Update the challenge to reference the correct definition + challenge.Spec.Definition = definitionName + + err = client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // Get the challenge from client to ensure it exists + err = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + Expect(err).NotTo(HaveOccurred()) + + err = reconciler.loadChallengeDefinition(ctx, ctrl.Request{}, challenge) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should load pod efficiently", func() { + // Create a fresh challenge for each iteration with unique name + challengeName := fmt.Sprintf("benchmark-challenge-%d", GinkgoParallelProcess()) + podName := fmt.Sprintf("benchmark-pod-%d", GinkgoParallelProcess()) + challenge := NewChallengeBuilder(). + WithName(challengeName). + WithNamespace("default"). + WithPodName(podName). + Build() + + // Create challenge in client + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // Get the challenge from client to ensure it exists + err = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + Expect(err).NotTo(HaveOccurred()) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: "default", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "benchmark-container", + Image: "nginx:latest", + }, + }, + }, + } + + err = reconciler.loadPod(ctx, challenge, pod) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should load service efficiently", func() { + // Create a fresh challenge for each iteration with unique name + challengeName := fmt.Sprintf("benchmark-challenge-%d", GinkgoParallelProcess()) + challenge := NewChallengeBuilder(). + WithName(challengeName). + WithNamespace("default"). + Build() + + // Set the required labels for service creation + serviceName := fmt.Sprintf("benchmark-service-%d", GinkgoParallelProcess()) + challenge.Labels["apps.hexactf.io/svcName"] = serviceName + + // Create challenge in client + err := client.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + // Get the challenge from client to ensure it exists + err = client.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, challenge) + Expect(err).NotTo(HaveOccurred()) + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceName, + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + } + + err = reconciler.loadService(ctx, challenge, service) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should get challenge definition efficiently", func() { + // Create a fresh challenge for each iteration with unique name + challengeName := fmt.Sprintf("benchmark-challenge-%d", GinkgoParallelProcess()) + definitionName := fmt.Sprintf("benchmark-definition-%d", GinkgoParallelProcess()) + + challenge := NewChallengeBuilder(). + WithName(challengeName). + WithNamespace("default"). + Build() + + // Update the challenge to reference the correct definition + challenge.Spec.Definition = definitionName + + // Create a fresh definition for each iteration + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: definitionName, + Namespace: "default", + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "benchmark-container", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } + + // Create definition in client + err := client.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + _, err = reconciler.getChallengeDefinition(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + }) +}) diff --git a/internal/controller/v2/custom_metrics.go b/internal/controller/v2/custom_metrics.go deleted file mode 100644 index 060d881..0000000 --- a/internal/controller/v2/custom_metrics.go +++ /dev/null @@ -1,21 +0,0 @@ -package controller - -import ( - "github.com/prometheus/client_golang/prometheus" - "sigs.k8s.io/controller-runtime/pkg/metrics" -) - -var ( - crStatusMetric = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "challenge_resource_status", - Help: "Tracks the status of the custom resource", - }, - []string{"challeng_id", "challenge_name", "username", "namespace"}, - ) -) - -func init() { - metrics.Registry.MustRegister(crStatusMetric) - -} diff --git a/internal/controller/v2/finalizer_test.go b/internal/controller/v2/finalizer_test.go index 119fc5e..6720333 100644 --- a/internal/controller/v2/finalizer_test.go +++ b/internal/controller/v2/finalizer_test.go @@ -2,11 +2,10 @@ package controller import ( "context" - "testing" hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -14,11 +13,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -func TestFinalizerOperations(t *testing.T) { - // 스키마 설정 - s := runtime.NewScheme() - _ = scheme.AddToScheme(s) - _ = hexactfproj.AddToScheme(s) +var _ = Describe("Finalizer Operations", func() { + var ( + s *runtime.Scheme + ) + + BeforeEach(func() { + // 스키마 설정 + s = runtime.NewScheme() + _ = scheme.AddToScheme(s) + _ = hexactfproj.AddToScheme(s) + }) // 테스트용 Challenge 생성 헬퍼 함수 createTestChallenge := func(name string, finalizers []string) *hexactfproj.Challenge { @@ -31,176 +36,236 @@ func TestFinalizerOperations(t *testing.T) { } } - t.Run("addFinalizer", func(t *testing.T) { - tests := []struct { - name string - challenge *hexactfproj.Challenge - wantErr bool - }{ - { - name: "finalizer 추가 성공", - challenge: createTestChallenge("test-1", nil), - wantErr: false, - }, - { - name: "이미 finalizer가 있는 경우", - challenge: createTestChallenge("test-2", []string{challengeFinalizer}), - wantErr: false, - }, - } + Describe("AddFinalizer", func() { + Context("when finalizer is added successfully", func() { + It("should add finalizer successfully", func() { + // Given + challenge := createTestChallenge("test-1", nil) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { // fake client 생성 client := fake.NewClientBuilder(). WithScheme(s). - WithObjects(tt.challenge). + WithObjects(challenge). Build() r := &ChallengeReconciler{Client: client} - // addFinalizer 실행 - _, err := r.addFinalizer(context.Background(), tt.challenge) + // When + _, err := r.addFinalizer(context.Background(), challenge) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) + // Then + Expect(err).NotTo(HaveOccurred()) // Challenge 다시 가져와서 finalizer 확인 updated := &hexactfproj.Challenge{} err = client.Get(context.Background(), types.NamespacedName{ - Name: tt.challenge.Name, - Namespace: tt.challenge.Namespace, + Name: challenge.Name, + Namespace: challenge.Namespace, }, updated) - require.NoError(t, err) - assert.Contains(t, updated.Finalizers, challengeFinalizer) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Finalizers).To(ContainElement(challengeFinalizer)) }) - } + }) + + Context("when finalizer already exists", func() { + It("should handle existing finalizer", func() { + // Given + challenge := createTestChallenge("test-2", []string{challengeFinalizer}) + + // fake client 생성 + client := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(challenge). + Build() + + r := &ChallengeReconciler{Client: client} + + // When + _, err := r.addFinalizer(context.Background(), challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + + // Challenge 다시 가져와서 finalizer 확인 + updated := &hexactfproj.Challenge{} + err = client.Get(context.Background(), + types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, + updated) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Finalizers).To(ContainElement(challengeFinalizer)) + }) + }) }) - t.Run("removeFinalizer", func(t *testing.T) { - tests := []struct { - name string - challenge *hexactfproj.Challenge - wantErr bool - }{ - { - name: "finalizer 제거 성공", - challenge: createTestChallenge("test-3", []string{challengeFinalizer}), - wantErr: false, - }, - { - name: "finalizer가 없는 경우", - challenge: createTestChallenge("test-4", nil), - wantErr: false, - }, - } + Describe("RemoveFinalizer", func() { + Context("when finalizer is removed successfully", func() { + It("should remove finalizer successfully", func() { + // Given + challenge := createTestChallenge("test-3", []string{challengeFinalizer}) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { // fake client 생성 client := fake.NewClientBuilder(). WithScheme(s). - WithObjects(tt.challenge). + WithObjects(challenge). Build() r := &ChallengeReconciler{Client: client} - // removeFinalizer 실행 - err := r.removeFinalizer(context.Background(), tt.challenge) + // When + err := r.removeFinalizer(context.Background(), challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) - if tt.wantErr { - require.Error(t, err) - return + // Challenge가 존재하는 경우 finalizer 확인 + updated := &hexactfproj.Challenge{} + err = client.Get(context.Background(), + types.NamespacedName{ + Name: challenge.Name, + Namespace: challenge.Namespace, + }, + updated) + + if err == nil { + Expect(updated.Finalizers).NotTo(ContainElement(challengeFinalizer)) } - require.NoError(t, err) + }) + }) + + Context("when finalizer does not exist", func() { + It("should handle missing finalizer", func() { + // Given + challenge := createTestChallenge("test-4", nil) + + // fake client 생성 + client := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(challenge). + Build() + + r := &ChallengeReconciler{Client: client} + + // When + err := r.removeFinalizer(context.Background(), challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) // Challenge가 존재하는 경우 finalizer 확인 updated := &hexactfproj.Challenge{} err = client.Get(context.Background(), types.NamespacedName{ - Name: tt.challenge.Name, - Namespace: tt.challenge.Namespace, + Name: challenge.Name, + Namespace: challenge.Namespace, }, updated) if err == nil { - assert.NotContains(t, updated.Finalizers, challengeFinalizer) + Expect(updated.Finalizers).NotTo(ContainElement(challengeFinalizer)) } }) - } + }) }) -} - -func TestFinalizerHelperFunctions(t *testing.T) { - t.Run("containsString", func(t *testing.T) { - tests := []struct { - name string - slice []string - expected bool - }{ - { - name: "빈 슬라이스", - slice: []string{}, - expected: false, - }, - { - name: "finalizer 포함", - slice: []string{challengeFinalizer, "other-finalizer"}, - expected: true, - }, - { - name: "finalizer 미포함", - slice: []string{"other-finalizer"}, - expected: false, - }, - } +}) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := containsString(tt.slice) - assert.Equal(t, tt.expected, result) +var _ = Describe("Finalizer Helper Functions", func() { + Describe("containsString", func() { + Context("with empty slice", func() { + It("should return false", func() { + // Given + slice := []string{} + + // When + result := containsString(slice) + + // Then + Expect(result).To(BeFalse()) }) - } + }) + + Context("with finalizer included", func() { + It("should return true", func() { + // Given + slice := []string{challengeFinalizer, "other-finalizer"} + + // When + result := containsString(slice) + + // Then + Expect(result).To(BeTrue()) + }) + }) + + Context("with finalizer not included", func() { + It("should return false", func() { + // Given + slice := []string{"other-finalizer"} + + // When + result := containsString(slice) + + // Then + Expect(result).To(BeFalse()) + }) + }) }) - t.Run("removeString", func(t *testing.T) { - tests := []struct { - name string - slice []string - expected []string - }{ - { - name: "빈 슬라이스", - slice: []string{}, - expected: nil, - }, - { - name: "finalizer만 있는 경우", - slice: []string{challengeFinalizer}, - expected: nil, - }, - { - name: "여러 finalizer가 있는 경우", - slice: []string{challengeFinalizer, "other-finalizer"}, - expected: []string{"other-finalizer"}, - }, - { - name: "다른 finalizer만 있는 경우", - slice: []string{"other-finalizer"}, - expected: []string{"other-finalizer"}, - }, - } + Describe("removeString", func() { + Context("with empty slice", func() { + It("should return nil", func() { + // Given + slice := []string{} + + // When + result := removeString(slice) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := removeString(tt.slice) - assert.Equal(t, tt.expected, result) + // Then + Expect(result).To(BeNil()) }) - } + }) + + Context("with only finalizer", func() { + It("should return nil", func() { + // Given + slice := []string{challengeFinalizer} + + // When + result := removeString(slice) + + // Then + Expect(result).To(BeNil()) + }) + }) + + Context("with multiple finalizers", func() { + It("should return other finalizers", func() { + // Given + slice := []string{challengeFinalizer, "other-finalizer"} + + // When + result := removeString(slice) + + // Then + Expect(result).To(Equal([]string{"other-finalizer"})) + }) + }) + + Context("with other finalizers only", func() { + It("should return unchanged slice", func() { + // Given + slice := []string{"other-finalizer"} + + // When + result := removeString(slice) + + // Then + Expect(result).To(Equal([]string{"other-finalizer"})) + }) + }) }) -} +}) diff --git a/internal/controller/v2/handler.go b/internal/controller/v2/handler.go index 8d6bf10..3a61667 100644 --- a/internal/controller/v2/handler.go +++ b/internal/controller/v2/handler.go @@ -26,10 +26,6 @@ func (r *ChallengeReconciler) initializeChallenge(ctx context.Context, challenge return fmt.Errorf("failed to initialize status: %w", err) } - // if err := r.KafkaClient.SendStatusChange(challenge.Labels["apps.hexactf.io/userId"], challenge.Labels["apps.hexactf.io/challengeId"], "Pending"); err != nil { - // return fmt.Errorf("failed to send status change: %w", err) - // } - return nil } @@ -54,16 +50,6 @@ func (r *ChallengeReconciler) handlePendingState(ctx context.Context, challenge log.Error(err, "Failed to update Challenge status", "challenge", challenge.Name) } - // Convert endpoint to string - // endpoint := "" - // if challenge.Status.Endpoint != 0 { - // endpoint = fmt.Sprintf("%d", challenge.Status.Endpoint) - // } - - // Send Message - // if err := r.KafkaClient.SendStatusChangeWithEndpoint(challenge.Labels["apps.hexactf.io/userId"], challenge.Labels["apps.hexactf.io/challengeId"], "Running", endpoint); err != nil { - // log.Error(err, "Failed to send status change: %w", err) - // } } return ctrl.Result{RequeueAfter: requeueInterval}, nil @@ -117,13 +103,6 @@ func (r *ChallengeReconciler) handleDeletion(ctx context.Context, challenge *hex return ctrl.Result{RequeueAfter: time.Second * 5}, err } - // Send message to queue - // sendErr := r.KafkaClient.SendStatusChange(challenge.Labels["apps.hexactf.io/userId"], challenge.Labels["apps.hexactf.io/challengeId"], "Deleted") - // if sendErr != nil { - // log.Error(sendErr, "Failed to send status change message") - // return ctrl.Result{}, sendErr - // } - } log.Info("Successfully completed deletion process") @@ -144,15 +123,6 @@ func (r *ChallengeReconciler) handleError(ctx context.Context, req ctrl.Request, return ctrl.Result{}, err } - // crStatusMetric.WithLabelValues(challenge.Labels["apps.hexactf.io/challengeId"], challenge.Name, challenge.Labels["apps.hexactf.io/user"], challenge.Namespace).Set(3) - - // Send message to queue - // sendErr := r.KafkaClient.SendStatusChange(challenge.Labels["apps.hexactf.io/userId"], challenge.Labels["apps.hexactf.io/challengeId"], "Error") - // if sendErr != nil { - // log.Error(sendErr, "Failed to send status change message") - // return ctrl.Result{}, sendErr - // } - // 에러 발생 시 challenge 삭제 if deleteErr := r.Delete(ctx, challenge); deleteErr != nil { log.Error(deleteErr, "Failed to delete Challenge") diff --git a/internal/controller/v2/handler_test.go b/internal/controller/v2/handler_test.go new file mode 100644 index 0000000..06cd597 --- /dev/null +++ b/internal/controller/v2/handler_test.go @@ -0,0 +1,439 @@ +package controller + +import ( + "context" + "errors" + "time" + + hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type MockClient struct { + mock.Mock + client.Client +} + +func (m *MockClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + args := m.Called(ctx, key, obj, opts) + return args.Error(0) +} + +func (m *MockClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + args := m.Called(ctx, obj, opts) + return args.Error(0) +} + +func (m *MockClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + args := m.Called(ctx, obj, opts) + return args.Error(0) +} + +func (m *MockClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + args := m.Called(ctx, obj, opts) + return args.Error(0) +} + +func (m *MockClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + args := m.Called(ctx, obj, patch, opts) + return args.Error(0) +} + +func (m *MockClient) Status() client.StatusWriter { + args := m.Called() + return args.Get(0).(client.StatusWriter) +} + +type MockStatusWriter struct { + mock.Mock +} + +func (m *MockStatusWriter) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + args := m.Called(ctx, obj, opts) + return args.Error(0) +} + +func (m *MockStatusWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + args := m.Called(ctx, obj, patch, opts) + return args.Error(0) +} + +func (m *MockStatusWriter) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + args := m.Called(ctx, obj, subResource, opts) + return args.Error(0) +} + +var _ = Describe("Challenge Reconciler Handler", func() { + var ( + reconciler *TestChallengeReconciler + mockClient *MockClient + mockStatus *MockStatusWriter + ctx context.Context + challenge *hexactfproj.Challenge + requeueInterval time.Duration + ) + + BeforeEach(func() { + ctx = context.Background() + mockClient = new(MockClient) + mockStatus = new(MockStatusWriter) + requeueInterval = time.Minute // 실제 값과 맞춤 + + challenge = &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + Labels: map[string]string{ + "apps.hexactf.io/podName": "test-pod", + }, + }, + Spec: hexactfproj.ChallengeSpec{ + Definition: "test-definition", + }, + Status: hexactfproj.ChallengeStatus{ + StartedAt: &metav1.Time{Time: time.Now()}, + CurrentStatus: *hexactfproj.NewCurrentStatus(), + }, + } + + reconciler = &TestChallengeReconciler{ + ChallengeReconciler: &ChallengeReconciler{Client: mockClient}, + } + }) + + AfterEach(func() { + mockClient.AssertExpectations(GinkgoT()) + mockStatus.AssertExpectations(GinkgoT()) + }) + + Describe("InitializeChallenge", func() { + Context("when initialization succeeds", func() { + It("should initialize challenge successfully", func() { + // Given + // loadChallengeDefinition을 mock하여 우회 + reconciler.mockLoadDefinition = func(ctx context.Context, req ctrl.Request, challenge *hexactfproj.Challenge) error { + return nil // 성공 + } + + // Status mock 설정 + mockClient.On("Status").Return(mockStatus) + mockStatus.On("Update", ctx, challenge, mock.Anything).Return(nil) + + // When + err := reconciler.initializeChallenge(ctx, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(challenge.Status.CurrentStatus.Status).To(Equal("Pending")) + Expect(challenge.Status.StartedAt).NotTo(BeNil()) + }) + }) + + Context("when status update fails", func() { + It("should return an error", func() { + // Given + // loadChallengeDefinition을 mock하여 우회 + reconciler.mockLoadDefinition = func(ctx context.Context, req ctrl.Request, challenge *hexactfproj.Challenge) error { + return nil // 성공 + } + + expectedError := errors.New("status update failed") + mockClient.On("Status").Return(mockStatus) + mockStatus.On("Update", ctx, challenge, mock.Anything).Return(expectedError) + + // When + err := reconciler.initializeChallenge(ctx, challenge) + + // Then + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to initialize status")) + }) + }) + }) + + Describe("HandlePendingState", func() { + Context("when pod is not found", func() { + It("should handle pod not found", func() { + // Given + expectedError := errors.New("pod not found") + mockClient.On("Get", ctx, types.NamespacedName{ + Name: "test-pod", + Namespace: "default", + }, mock.AnythingOfType("*v1.Pod"), mock.Anything).Return(expectedError) + + // When + result, err := reconciler.handlePendingState(ctx, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Context("when pod is running", func() { + It("should handle running pod", func() { + // Given + runningPod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + mockClient.On("Get", ctx, types.NamespacedName{ + Name: "test-pod", + Namespace: "default", + }, mock.AnythingOfType("*v1.Pod"), mock.Anything).Run(func(args mock.Arguments) { + pod := args.Get(2).(*corev1.Pod) + *pod = *runningPod + }).Return(nil) + + mockClient.On("Status").Return(mockStatus) + mockStatus.On("Update", ctx, challenge, mock.Anything).Return(nil) + + // When + result, err := reconciler.handlePendingState(ctx, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{RequeueAfter: requeueInterval})) + Expect(challenge.Status.CurrentStatus.Status).To(Equal("Running")) + }) + }) + + Context("when pod name is not set", func() { + It("should handle missing pod name", func() { + // Given + challengeWithoutPodName := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + Labels: map[string]string{}, // podName이 없음 + }, + } + + // handleError 메서드를 모킹하기 위해 필요한 mock 설정 + mockClient.On("Get", ctx, ctrl.Request{}.NamespacedName, challengeWithoutPodName, mock.Anything).Return(nil) + mockClient.On("Status").Return(mockStatus) + mockStatus.On("Update", ctx, challengeWithoutPodName, mock.Anything).Return(nil) + mockClient.On("Delete", ctx, challengeWithoutPodName, mock.Anything).Return(nil) + + // When + _, err := reconciler.handlePendingState(ctx, challengeWithoutPodName) + + // Then + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("podName is not set")) + }) + }) + }) + + Describe("HandleRunningState", func() { + Context("when time limit is exceeded", func() { + It("should handle time exceeded", func() { + // Given + oldTime := metav1.Time{Time: time.Now().Add(-10 * time.Minute)} // 5분 제한을 초과 + challenge.Status.StartedAt = &oldTime + challenge.Status.CurrentStatus.Running() + + mockClient.On("Get", ctx, types.NamespacedName{ + Name: "test-challenge", + Namespace: "default", + }, challenge, mock.Anything).Return(nil) + + // noTimeCondition이 true이므로 실제로는 시간 초과 로직이 실행되지 않음 + // 대신 noTimeCondition에 따른 동작을 테스트 + + // When + result, err := reconciler.handleRunningState(ctx, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + // noTimeCondition이 true이므로 RequeueAfter가 설정됨 + Expect(result).To(Equal(ctrl.Result{RequeueAfter: requeueInterval})) + Expect(challenge.Status.CurrentStatus.Status).To(Equal("Running")) + }) + }) + + Context("when within time limit", func() { + It("should handle within time limit", func() { + // Given + recentTime := metav1.Time{Time: time.Now().Add(-1 * time.Minute)} // 5분 제한 내 + challenge.Status.StartedAt = &recentTime + + mockClient.On("Get", ctx, client.ObjectKey{ + Name: "test-challenge", + Namespace: "default", + }, challenge, mock.Anything).Return(nil) + + // When + result, err := reconciler.handleRunningState(ctx, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{RequeueAfter: requeueInterval})) + }) + }) + }) + + Describe("HandleDeletion", func() { + Context("when finalizer exists", func() { + It("should handle deletion with finalizer", func() { + // Given + challenge.Finalizers = []string{"challenge.hexactf.io/finalizer"} + mockClient.On("Update", ctx, challenge, mock.Anything).Return(nil) + + // When + result, err := reconciler.handleDeletion(ctx, challenge) + + // Then + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + Expect(challenge.Finalizers).To(BeEmpty()) + }) + }) + + Context("when update fails", func() { + It("should handle update error", func() { + // Given + challenge.Finalizers = []string{"challenge.hexactf.io/finalizer"} + expectedError := errors.New("update failed") + mockClient.On("Update", ctx, challenge, mock.Anything).Return(expectedError) + + // When + result, err := reconciler.handleDeletion(ctx, challenge) + + // Then + Expect(err).To(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{RequeueAfter: time.Second * 5})) + }) + }) + }) + + Describe("HandleError", func() { + Context("when error handling succeeds", func() { + It("should handle error successfully", func() { + // Given + testError := errors.New("test error") + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "test-challenge", + Namespace: "default", + }, + } + + mockClient.On("Get", ctx, req.NamespacedName, challenge, mock.Anything).Return(nil) + mockClient.On("Status").Return(mockStatus) + mockStatus.On("Update", ctx, challenge, mock.Anything).Return(nil) + mockClient.On("Delete", ctx, challenge, mock.Anything).Return(nil) + + // When + result, err := reconciler.handleError(ctx, req, challenge, testError) + + // Then + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(testError)) + Expect(result).To(Equal(ctrl.Result{})) + Expect(challenge.Status.CurrentStatus.Status).To(Equal("Error")) + }) + }) + + Context("when delete fails", func() { + It("should handle delete failure", func() { + // Given + testError := errors.New("test error") + deleteError := errors.New("delete failed") + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "test-challenge", + Namespace: "default", + }, + } + + mockClient.On("Get", ctx, req.NamespacedName, challenge, mock.Anything).Return(nil) + mockClient.On("Status").Return(mockStatus) + mockStatus.On("Update", ctx, challenge, mock.Anything).Return(nil) + mockClient.On("Delete", ctx, challenge, mock.Anything).Return(deleteError) + + // When + result, err := reconciler.handleError(ctx, req, challenge, testError) + + // Then + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(deleteError)) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + }) +}) + +var _ = Describe("CurrentStatus", func() { + It("should handle status transitions correctly", func() { + status := hexactfproj.NewCurrentStatus() + + // 초기 상태 확인 + Expect(status.Status).To(Equal("Pending")) + Expect(status.IsPending()).To(BeTrue()) + + // Running 상태 테스트 + status.Running() + Expect(status.Status).To(Equal("Running")) + Expect(status.IsRunning()).To(BeTrue()) + Expect(status.IsPending()).To(BeFalse()) + + // Terminating 상태 테스트 + status.Terminating() + Expect(status.Status).To(Equal("Terminating")) + Expect(status.IsTerminating()).To(BeTrue()) + Expect(status.IsRunning()).To(BeFalse()) + + // Error 상태 테스트 + testError := errors.New("test error") + status.Error(testError) + Expect(status.Status).To(Equal("Error")) + + // Deleted 상태 테스트 + status.Deleted() + Expect(status.Status).To(Equal("Deleted")) + Expect(status.IsDeleted()).To(BeTrue()) + }) +}) + +var _ = Describe("Challenge Reconciler Performance", func() { + It("should handle pending state efficiently", func() { + // 벤치마크 setup + mockClient := new(MockClient) + mockStatus := new(MockStatusWriter) + reconciler := &ChallengeReconciler{Client: mockClient} + ctx := context.Background() + + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "benchmark-challenge", + Namespace: "default", + Labels: map[string]string{ + "apps.hexactf.io/podName": "benchmark-pod", + }, + }, + } + + runningPod := &corev1.Pod{ + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + + mockClient.On("Get", ctx, mock.Anything, mock.AnythingOfType("*v1.Pod"), mock.Anything).Run(func(args mock.Arguments) { + pod := args.Get(2).(*corev1.Pod) + *pod = *runningPod + }).Return(nil) + mockClient.On("Status").Return(mockStatus) + mockStatus.On("Update", ctx, challenge, mock.Anything).Return(nil) + + reconciler.handlePendingState(ctx, challenge) + }) +}) diff --git a/internal/controller/v2/suite_test.go b/internal/controller/v2/suite_test.go index 96a2e73..028252d 100644 --- a/internal/controller/v2/suite_test.go +++ b/internal/controller/v2/suite_test.go @@ -18,18 +18,15 @@ package controller import ( "context" - "fmt" - "path/filepath" - "runtime" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/client/fake" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -40,57 +37,35 @@ import ( // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -var cfg *rest.Config var k8sClient client.Client -var testEnv *envtest.Environment var ctx context.Context var cancel context.CancelFunc -func TestControllers(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "Controller Suite") -} - var _ = BeforeSuite(func() { logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) ctx, cancel = context.WithCancel(context.TODO()) By("bootstrapping test environment") - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, - - // The BinaryAssetsDirectory is only required if you want to run the tests directly - // without call the makefile target test. If not informed it will look for the - // default path defined in controller-runtime which is /usr/local/kubebuilder/. - // Note that you must have the required binaries setup under the bin directory to perform - // the tests directly. When we run make test it will be setup and used automatically. - BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", - fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)), - } - - var err error - // cfg is defined in this file globally. - cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) - - err = appsv2alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - // +kubebuilder:scaffold:scheme - - k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient).NotTo(BeNil()) + // Use fake client instead of envtest for unit tests + scheme := runtime.NewScheme() + _ = appsv2alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + k8sClient = fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + Expect(k8sClient).NotTo(BeNil()) }) var _ = AfterSuite(func() { By("tearing down the test environment") cancel() - err := testEnv.Stop() - Expect(err).NotTo(HaveOccurred()) }) + +// TestControllers는 모든 테스트를 실행하는 메인 함수 +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Controller Suite") +} diff --git a/internal/controller/v2/test_helpers.go b/internal/controller/v2/test_helpers.go new file mode 100644 index 0000000..362e615 --- /dev/null +++ b/internal/controller/v2/test_helpers.go @@ -0,0 +1,246 @@ +package controller + +import ( + "context" + "fmt" + "time" + + hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// TestChallengeReconciler는 테스트용 ChallengeReconciler를 래핑하는 구조체 +type TestChallengeReconciler struct { + *ChallengeReconciler + mockLoadDefinition func(context.Context, ctrl.Request, *hexactfproj.Challenge) error +} + +// loadChallengeDefinition을 테스트용으로 오버라이드 +func (r *TestChallengeReconciler) loadChallengeDefinition(ctx context.Context, req ctrl.Request, challenge *hexactfproj.Challenge) error { + if r.mockLoadDefinition != nil { + return r.mockLoadDefinition(ctx, req, challenge) + } + return nil // 기본적으로는 성공으로 처리 +} + +// initializeChallenge을 테스트용으로 오버라이드 +func (r *TestChallengeReconciler) initializeChallenge(ctx context.Context, challenge *hexactfproj.Challenge) error { + // loadChallengeDefinition을 mock으로 우회 + if r.mockLoadDefinition != nil { + if err := r.mockLoadDefinition(ctx, ctrl.Request{}, challenge); err != nil { + return fmt.Errorf("failed to load challenge definition: %w", err) + } + } + + // initialize status + // default status is Pending + now := metav1.Now() + challenge.Status.StartedAt = &now + challenge.Status.CurrentStatus = *hexactfproj.NewCurrentStatus() + + if err := r.Status().Update(ctx, challenge); err != nil { + return fmt.Errorf("failed to initialize status: %w", err) + } + + return nil +} + +// NewTestChallengeReconciler는 테스트용 reconciler를 생성 +func NewTestChallengeReconciler(client client.Client) *TestChallengeReconciler { + return &TestChallengeReconciler{ + ChallengeReconciler: &ChallengeReconciler{ + Client: client, + }, + } +} + +// ChallengeBuilder는 테스트용 Challenge 객체를 빌드하기 위한 빌더 패턴 +type ChallengeBuilder struct { + challenge *hexactfproj.Challenge +} + +// NewChallengeBuilder는 새로운 ChallengeBuilder를 생성 +func NewChallengeBuilder() *ChallengeBuilder { + return &ChallengeBuilder{ + challenge: &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-challenge", + Namespace: "default", + Labels: map[string]string{ + "apps.hexactf.io/challengeId": "test-challenge-id", + "apps.hexactf.io/userId": "test-user-id", + }, + }, + Spec: hexactfproj.ChallengeSpec{ + Definition: "test-definition", + }, + Status: hexactfproj.ChallengeStatus{ + StartedAt: &metav1.Time{Time: time.Now()}, + CurrentStatus: *hexactfproj.NewCurrentStatus(), + }, + }, + } +} + +// WithName은 Challenge의 이름을 설정 +func (b *ChallengeBuilder) WithName(name string) *ChallengeBuilder { + b.challenge.Name = name + return b +} + +// WithNamespace는 Challenge의 네임스페이스를 설정 +func (b *ChallengeBuilder) WithNamespace(namespace string) *ChallengeBuilder { + b.challenge.Namespace = namespace + return b +} + +// WithPodName은 Challenge의 podName 라벨을 설정 +func (b *ChallengeBuilder) WithPodName(podName string) *ChallengeBuilder { + b.challenge.Labels["apps.hexactf.io/podName"] = podName + return b +} + +// WithStatus는 Challenge의 상태를 설정 +func (b *ChallengeBuilder) WithStatus(status string) *ChallengeBuilder { + b.challenge.Status.CurrentStatus.Status = status + return b +} + +// WithStartedAt은 Challenge의 시작 시간을 설정 +func (b *ChallengeBuilder) WithStartedAt(startedAt time.Time) *ChallengeBuilder { + b.challenge.Status.StartedAt = &metav1.Time{Time: startedAt} + return b +} + +// WithDeletionTimestamp는 Challenge의 삭제 타임스탬프를 설정 +func (b *ChallengeBuilder) WithDeletionTimestamp(timestamp time.Time) *ChallengeBuilder { + metaTime := metav1.Time{Time: timestamp} + b.challenge.DeletionTimestamp = &metaTime + return b +} + +// WithFinalizer는 Challenge에 finalizer를 추가 +func (b *ChallengeBuilder) WithFinalizer(finalizer string) *ChallengeBuilder { + b.challenge.Finalizers = append(b.challenge.Finalizers, finalizer) + return b +} + +// Build는 구성된 Challenge 객체를 반환 +func (b *ChallengeBuilder) Build() *hexactfproj.Challenge { + return b.challenge.DeepCopy() +} + +// PodBuilder는 테스트용 Pod 객체를 빌드하기 위한 빌더 패턴 +type PodBuilder struct { + pod *corev1.Pod +} + +// NewPodBuilder는 새로운 PodBuilder를 생성 +func NewPodBuilder() *PodBuilder { + return &PodBuilder{ + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + } +} + +// WithName은 Pod의 이름을 설정 +func (b *PodBuilder) WithName(name string) *PodBuilder { + b.pod.Name = name + return b +} + +// WithNamespace는 Pod의 네임스페이스를 설정 +func (b *PodBuilder) WithNamespace(namespace string) *PodBuilder { + b.pod.Namespace = namespace + return b +} + +// WithPhase는 Pod의 단계를 설정 +func (b *PodBuilder) WithPhase(phase corev1.PodPhase) *PodBuilder { + b.pod.Status.Phase = phase + return b +} + +// Build는 구성된 Pod 객체를 반환 +func (b *PodBuilder) Build() *corev1.Pod { + return b.pod.DeepCopy() +} + +// CreateFakeClient는 테스트용 fake client를 생성 +func CreateFakeClient(objects ...client.Object) client.Client { + scheme := runtime.NewScheme() + _ = hexactfproj.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&hexactfproj.Challenge{}). + Build() +} + +// AssertChallengeStatus는 Challenge의 상태를 검증하는 헬퍼 함수 +func AssertChallengeStatus(t mock.TestingT, challenge *hexactfproj.Challenge, expectedStatus string) { + if challenge.Status.CurrentStatus.Status != expectedStatus { + t.Errorf("Expected status %s, but got %s", expectedStatus, challenge.Status.CurrentStatus.Status) + } +} + +// AssertPodExists는 클러스터에 Pod가 존재하는지 확인하는 헬퍼 함수 +func AssertPodExists(t mock.TestingT, client client.Client, ctx context.Context, name, namespace string) { + pod := &corev1.Pod{} + key := types.NamespacedName{Name: name, Namespace: namespace} + err := client.Get(ctx, key, pod) + if err != nil { + t.Errorf("Expected pod %s/%s to exist, but got error: %v", namespace, name, err) + } +} + +// AssertChallengeNotExists는 클러스터에 Challenge가 존재하지 않는지 확인하는 헬퍼 함수 +func AssertChallengeNotExists(t mock.TestingT, client client.Client, ctx context.Context, name, namespace string) { + challenge := &hexactfproj.Challenge{} + key := types.NamespacedName{Name: name, Namespace: namespace} + err := client.Get(ctx, key, challenge) + if err == nil { + t.Errorf("Expected challenge %s/%s to not exist, but it was found", namespace, name) + } +} + +// MockChallengeReconcilerOptions는 mock reconciler 생성 옵션 +type MockChallengeReconcilerOptions struct { + Client client.Client + RequeueInterval time.Duration + ChallengeDuration time.Duration + NoTimeCondition bool +} + +// NewMockChallengeReconciler는 설정 가능한 mock reconciler를 생성 +func NewMockChallengeReconciler(opts MockChallengeReconcilerOptions) *TestChallengeReconciler { + if opts.Client == nil { + opts.Client = CreateFakeClient() + } + if opts.RequeueInterval == 0 { + opts.RequeueInterval = time.Second * 10 + } + if opts.ChallengeDuration == 0 { + opts.ChallengeDuration = time.Minute * 5 + } + + reconciler := NewTestChallengeReconciler(opts.Client) + + return reconciler +} diff --git a/internal/utils/labels.go b/internal/controller/v2/utils/labels.go similarity index 100% rename from internal/utils/labels.go rename to internal/controller/v2/utils/labels.go diff --git a/internal/utils/labels_test.go b/internal/controller/v2/utils/labels_test.go similarity index 100% rename from internal/utils/labels_test.go rename to internal/controller/v2/utils/labels_test.go diff --git a/internal/utils/name.go b/internal/controller/v2/utils/name.go similarity index 100% rename from internal/utils/name.go rename to internal/controller/v2/utils/name.go diff --git a/internal/utils/name_test.go b/internal/controller/v2/utils/name_test.go similarity index 100% rename from internal/utils/name_test.go rename to internal/controller/v2/utils/name_test.go diff --git a/internal/kafka/kafka.go b/internal/kafka/kafka.go deleted file mode 100644 index 8a93364..0000000 --- a/internal/kafka/kafka.go +++ /dev/null @@ -1,107 +0,0 @@ -package kafka - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/IBM/sarama" -) - -const ( - kafkaTopic = "challenge-status" -) - -// KafkaProducer Kafka producer -type KafkaProducer struct { - producer sarama.SyncProducer -} - -// StatusMessage 는 Kafka에 보낼 메세지 -type StatusMessage struct { - UserId string `json:"userId"` - ProblemID string `json:"problemId"` - NewStatus string `json:"newStatus"` - Endpoint string `json:"endpoint"` - Timestamp time.Time `json:"timestamp"` -} - -// NewKafkaProducer Kafka producer 객체 생성 -// Producer 관련 설정 수행 -func NewKafkaProducer(brokers []string) (*KafkaProducer, error) { - config := sarama.NewConfig() - config.Producer.RequiredAcks = sarama.WaitForAll - config.Producer.Retry.Max = 5 - config.Producer.Return.Successes = true - - var producer sarama.SyncProducer - var err error - maxRetries := 10 - retryInterval := time.Second * 10 - - for i := 0; i < maxRetries; i++ { - producer, err = sarama.NewSyncProducer(brokers, config) - if err == nil { - break - } - //log.Info("Failed to connect to Kafka, retrying...", - // "attempt", i+1, - // "maxRetries", maxRetries, - // "error", err) - time.Sleep(retryInterval) - } - if err != nil { - return nil, fmt.Errorf("failed to create Kafka producer: %w", err) - } - - return &KafkaProducer{ - producer: producer, - }, nil -} - -// SendStatusChange 상태 메세지를 보낼때 사용된다. -func (k *KafkaProducer) SendStatusChange(userId, problemId, newStatus string) error { - return k.SendStatusChangeWithEndpoint(userId, problemId, newStatus, "") -} - -// SendStatusChangeWithEndpoint 상태 메세지를 보낼때 사용된다. -func (k *KafkaProducer) SendStatusChangeWithEndpoint(userId, problemId, newStatus, endpoint string) error { - if k == nil { - return fmt.Errorf("KafkaProducer instance is nil") - } - if k.producer == nil { - return fmt.Errorf("internal Kafka producer is nil") - } - msg := StatusMessage{ - UserId: userId, - ProblemID: problemId, - NewStatus: newStatus, - Endpoint: endpoint, - Timestamp: time.Now(), - } - - payload, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal status message: %w", err) - } - - _, _, err = k.producer.SendMessage(&sarama.ProducerMessage{ - Topic: kafkaTopic, - Value: sarama.StringEncoder(payload), - Key: sarama.StringEncoder(fmt.Sprintf("%s-%s", userId, problemId)), - }) - - if err != nil { - return fmt.Errorf("failed to send Kafka message: %w", err) - } - - return nil -} - -// Close Kafka producer 종료 -func (k *KafkaProducer) Close() error { - if k.producer != nil { - return k.producer.Close() - } - return nil -} diff --git a/sample/v1/challenge/ubuntu-challenge.yaml b/sample/v1/challenge/ubuntu-challenge.yaml deleted file mode 100644 index 8a35f1c..0000000 --- a/sample/v1/challenge/ubuntu-challenge.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: apps.hexactf.io/v1alpha1 -kind: Challenge -metadata: - name: ubuntu-instance-1 - namespace: default - labels: - apps.hexactf.io/challengeId: "1" - apps.hexactf.io/user: "test" -spec: - # Challenge가 생성될 namespace - namespace: default - # 사용할 ChallengeDefinition의 이름 - definition: ubuntu-basic \ No newline at end of file diff --git a/sample/v1/challenge/web-basic-challenge-1.yaml b/sample/v1/challenge/web-basic-challenge-1.yaml deleted file mode 100644 index 550d404..0000000 --- a/sample/v1/challenge/web-basic-challenge-1.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: apps.hexactf.io/v1alpha1 -kind: Challenge -metadata: - name: web-sample-1 - namespace: default - labels: - apps.hexactf.io/challengeId: "1" - apps.hexactf.io/user: "test" -spec: - namespace: default - definition: web-basic \ No newline at end of file diff --git a/sample/v1/challenge/web-basic-challenge-2.yaml b/sample/v1/challenge/web-basic-challenge-2.yaml deleted file mode 100644 index aeab957..0000000 --- a/sample/v1/challenge/web-basic-challenge-2.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: apps.hexactf.io/v1alpha1 -kind: Challenge -metadata: - name: web-instance-2 - namespace: default - labels: - apps.hexactf.io/challengeId: "2" - apps.hexactf.io/user: "helloworld" -spec: - namespace: default - definition: web-basic \ No newline at end of file diff --git a/sample/v1/definition/ubuntu-basic.yaml b/sample/v1/definition/ubuntu-basic.yaml deleted file mode 100644 index 2534226..0000000 --- a/sample/v1/definition/ubuntu-basic.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: apps.hexactf.io/v1alpha1 -kind: ChallengeDefinition -metadata: - name: ubuntu-basic - namespace: default -spec: - isOne: false - components: - - name: ubuntu - deployment: - spec: - replicas: 1 - template: - spec: - containers: - - name: ubuntu - image: ubuntu:22.04 - command: ["/bin/bash", "-c"] - args: - - | - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - openssh-server \ - curl \ - wget \ - vim \ - net-tools \ - iputils-ping && \ - mkdir -p /run/sshd && \ - echo 'root:toor' | chpasswd && \ - sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \ - /usr/sbin/sshd && \ - sleep infinity - ports: - - containerPort: 22 - protocol: TCP - resources: - limits: - cpu: "500m" - memory: "512Mi" - requests: - cpu: "200m" - memory: "256Mi" - service: - spec: - ports: - - name: ssh - protocol: TCP - port: 22 - targetPort: 22 - type: NodePort \ No newline at end of file diff --git a/sample/v1/definition/web-basic.yaml b/sample/v1/definition/web-basic.yaml deleted file mode 100644 index 35e3290..0000000 --- a/sample/v1/definition/web-basic.yaml +++ /dev/null @@ -1,168 +0,0 @@ -# ChallengeDefinition -apiVersion: apps.hexactf.io/v1alpha1 -kind: ChallengeDefinition -metadata: - name: web-basic - namespace: default -spec: - isOne: false - components: - - name: web - deployment: - spec: - replicas: 1 - template: - spec: - nodeSelector: - hexact/env: prod - containers: - - name: nginx - image: nginx:1.25 - ports: - - containerPort: 80 - command: ["/bin/bash", "-c"] - args: - - | - cat > /etc/nginx/conf.d/default.conf << 'EOF' - server { - listen 80; - server_name _; - - location / { - root /usr/share/nginx/html; - index index.html; - } - - location /api/ { - proxy_pass http://localhost:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - } - EOF - - cat > /usr/share/nginx/html/index.html << 'EOF' - - - - Sample Challenge - - - -

Sample Challenge

-

Click the button to send request to Python server

- -
- - - - - EOF - - nginx -g 'daemon off;' - resources: - limits: - cpu: "200m" - memory: "256Mi" - requests: - cpu: "100m" - memory: "128Mi" - - - name: python - image: python:3.9-slim - ports: - - containerPort: 5000 - command: ["/bin/bash", "-c"] - args: - - | - pip install flask gunicorn - - cat > app.py << 'EOF' - from flask import Flask, jsonify - - app = Flask(__name__) - - @app.route('/api/check') - def check(): - return jsonify({ - "message": "Hello from Python backend!", - "status": "success" - }) - - if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000) - EOF - - gunicorn --bind 0.0.0.0:5000 app:app - resources: - limits: - cpu: "300m" - memory: "256Mi" - requests: - cpu: "100m" - memory: "128Mi" - service: - spec: - ports: - - name: http - port: 80 - targetPort: 80 - protocol: TCP - type: NodePort diff --git a/test/integration/integration_suite_test.go b/test/integration/integration_suite_test.go new file mode 100644 index 0000000..66dd35e --- /dev/null +++ b/test/integration/integration_suite_test.go @@ -0,0 +1,13 @@ +package integration_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIntegration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Integration Suite") +} diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go new file mode 100644 index 0000000..fdb6237 --- /dev/null +++ b/test/integration/integration_test.go @@ -0,0 +1,369 @@ +package integration + +import ( + "context" + "fmt" + "time" + + hexactfproj "github.com/hexactf/challenge-operator/api/v2alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +var _ = Describe("Challenge Operator Integration", Ordered, func() { + var ( + k8sClient client.Client + ctx context.Context + namespace string + ) + + BeforeAll(func() { + By("Setting up the test environment") + + // Get the kubeconfig + cfg, err := config.GetConfig() + Expect(err).NotTo(HaveOccurred()) + + // Add our custom types to the scheme + err = hexactfproj.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // Create the client + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + + ctx = context.Background() + namespace = "default" + + By("CRDs should be pre-installed") + }) + + AfterAll(func() { + By("Cleaning up test namespace") + // Cleanup will be handled by individual tests + }) + + Describe("Challenge Definition Lifecycle", func() { + var definitionName string + + BeforeEach(func() { + definitionName = fmt.Sprintf("test-definition-%d", GinkgoParallelProcess()) + }) + + It("should create and manage challenge definitions", func() { + By("Creating a challenge definition") + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: definitionName, + Namespace: namespace, + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "test-container", + Image: "nginx:latest", + Ports: []hexactfproj.ContainerPort{ + { + ContainerPort: 80, + Protocol: "TCP", + }, + }, + }, + }, + }, + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + }, + }, + }, + } + + err := k8sClient.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the definition was created") + createdDefinition := &hexactfproj.ChallengeDefinition{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: definitionName, + Namespace: namespace, + }, createdDefinition) + Expect(err).NotTo(HaveOccurred()) + Expect(createdDefinition.Name).To(Equal(definitionName)) + + By("Cleaning up the definition") + err = k8sClient.Delete(ctx, createdDefinition) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Challenge Lifecycle", func() { + var ( + definitionName string + challengeName string + definition *hexactfproj.ChallengeDefinition + challenge *hexactfproj.Challenge + ) + + BeforeEach(func() { + definitionName = fmt.Sprintf("test-definition-%d", GinkgoParallelProcess()) + challengeName = fmt.Sprintf("test-challenge-%d", GinkgoParallelProcess()) + + // Create definition + definition = &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: definitionName, + Namespace: namespace, + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "test-container", + Image: "nginx:latest", + Ports: []hexactfproj.ContainerPort{ + { + ContainerPort: 80, + Protocol: "TCP", + }, + }, + }, + }, + }, + Service: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Port: 80, + NodePort: 30080, + }, + }, + }, + }, + }, + }, + } + + err := k8sClient.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + + // Create challenge + challenge = &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: challengeName, + Namespace: namespace, + Labels: map[string]string{ + "apps.hexactf.io/challengeId": fmt.Sprintf("challenge-id-%d", GinkgoParallelProcess()), + "apps.hexactf.io/userId": fmt.Sprintf("user-id-%d", GinkgoParallelProcess()), + }, + }, + Spec: hexactfproj.ChallengeSpec{ + Definition: definitionName, + }, + } + }) + + AfterEach(func() { + By("Cleaning up test resources") + if challenge != nil { + err := k8sClient.Delete(ctx, challenge) + if err != nil { + fmt.Printf("Failed to delete challenge: %v\n", err) + } + } + if definition != nil { + err := k8sClient.Delete(ctx, definition) + if err != nil { + fmt.Printf("Failed to delete definition: %v\n", err) + } + } + }) + + It("should create challenges successfully", func() { + By("Creating a challenge") + err := k8sClient.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the challenge was created") + createdChallenge := &hexactfproj.Challenge{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: challengeName, + Namespace: namespace, + }, createdChallenge) + Expect(err).NotTo(HaveOccurred()) + Expect(createdChallenge.Name).To(Equal(challengeName)) + + By("Verifying challenge spec is correct") + Expect(createdChallenge.Spec.Definition).To(Equal(definitionName)) + Expect(createdChallenge.Labels).To(HaveKey("apps.hexactf.io/challengeId")) + Expect(createdChallenge.Labels).To(HaveKey("apps.hexactf.io/userId")) + + By("Deleting the challenge") + err = k8sClient.Delete(ctx, createdChallenge) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Challenge Error Handling", func() { + var challengeName string + + BeforeEach(func() { + challengeName = fmt.Sprintf("error-challenge-%d-%d", GinkgoParallelProcess(), time.Now().Unix()) + }) + + It("should create challenge with invalid definition", func() { + By("Creating a challenge with non-existent definition") + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: challengeName, + Namespace: namespace, + Labels: map[string]string{ + "apps.hexactf.io/challengeId": fmt.Sprintf("error-challenge-id-%d", GinkgoParallelProcess()), + "apps.hexactf.io/userId": fmt.Sprintf("error-user-id-%d", GinkgoParallelProcess()), + }, + }, + Spec: hexactfproj.ChallengeSpec{ + Definition: "non-existent-definition", + }, + } + + err := k8sClient.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying the challenge was created") + createdChallenge := &hexactfproj.Challenge{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: challengeName, + Namespace: namespace, + }, createdChallenge) + Expect(err).NotTo(HaveOccurred()) + Expect(createdChallenge.Name).To(Equal(challengeName)) + + By("Verifying challenge spec is correct") + Expect(createdChallenge.Spec.Definition).To(Equal("non-existent-definition")) + + By("Cleaning up the challenge") + err = k8sClient.Delete(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Multiple Challenges", func() { + var ( + definitionName string + challenges []*hexactfproj.Challenge + ) + + BeforeEach(func() { + definitionName = fmt.Sprintf("multi-definition-%d", GinkgoParallelProcess()) + challenges = make([]*hexactfproj.Challenge, 0) + + // Create definition + definition := &hexactfproj.ChallengeDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: definitionName, + Namespace: namespace, + }, + Spec: hexactfproj.ChallengeDefinitionSpec{ + Resource: hexactfproj.Resource{ + Pod: &hexactfproj.PodConfig{ + Containers: []hexactfproj.ContainerConfig{ + { + Name: "multi-container", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } + + err := k8sClient.Create(ctx, definition) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + By("Cleaning up multiple challenges") + for _, challenge := range challenges { + err := k8sClient.Delete(ctx, challenge) + if err != nil { + fmt.Printf("Failed to delete challenge %s: %v\n", challenge.Name, err) + } + } + }) + + It("should create multiple challenges successfully", func() { + By("Creating multiple challenges") + for i := 0; i < 3; i++ { + challenge := &hexactfproj.Challenge{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("multi-challenge-%d-%d", GinkgoParallelProcess(), i), + Namespace: namespace, + Labels: map[string]string{ + "apps.hexactf.io/challengeId": fmt.Sprintf("multi-challenge-id-%d-%d", GinkgoParallelProcess(), i), + "apps.hexactf.io/userId": fmt.Sprintf("multi-user-id-%d-%d", GinkgoParallelProcess(), i), + }, + }, + Spec: hexactfproj.ChallengeSpec{ + Definition: definitionName, + }, + } + + err := k8sClient.Create(ctx, challenge) + Expect(err).NotTo(HaveOccurred()) + challenges = append(challenges, challenge) + } + + By("Verifying all challenges are created") + challengeList := &hexactfproj.ChallengeList{} + err := k8sClient.List(ctx, challengeList, client.InNamespace(namespace)) + Expect(err).NotTo(HaveOccurred()) + Expect(len(challengeList.Items)).To(BeNumerically(">=", 3)) + + By("Verifying challenge specs are correct") + for _, challenge := range challenges { + createdChallenge := &hexactfproj.Challenge{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: challenge.Name, + Namespace: namespace, + }, createdChallenge) + Expect(err).NotTo(HaveOccurred()) + Expect(createdChallenge.Spec.Definition).To(Equal(definitionName)) + } + }) + }) +}) + +// Helper function to check if error is "already exists" +func isAlreadyExistsError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return errStr == "admission webhook \"vchallenge.kb.io\" denied the request: resource already exists" || + errStr == "resource already exists" || + errStr == "namespaces \"integration-test\" already exists" +}