From 36a29d3dc552a4f81d49d9298013cd16a44ef6b8 Mon Sep 17 00:00:00 2001 From: Zespre Chang Date: Wed, 1 Apr 2026 23:14:21 +0800 Subject: [PATCH 1/6] feat(upgradelog): initial scaffold Signed-off-by: Zespre Chang --- PROJECT | 18 ++ api/v1beta1/upgradelog_types.go | 92 ++++++++++ api/v1beta1/zz_generated.deepcopy.go | 101 +++++++++++ cmd/main.go | 15 ++ ...anagement.harvesterhci.io_upgradelogs.yaml | 126 ++++++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/kustomization.yaml | 3 + config/rbac/role.yaml | 3 + config/rbac/upgradelog_admin_role.yaml | 27 +++ config/rbac/upgradelog_editor_role.yaml | 33 ++++ config/rbac/upgradelog_viewer_role.yaml | 29 ++++ config/samples/kustomization.yaml | 1 + .../management_v1beta1_upgradelog.yaml | 9 + internal/controller/upgradelog_controller.go | 63 +++++++ .../controller/upgradelog_controller_test.go | 84 +++++++++ internal/webhook/v1/pod_webhook.go | 119 +++++++++++++ internal/webhook/v1/pod_webhook_test.go | 86 +++++++++ internal/webhook/v1/webhook_suite_test.go | 164 ++++++++++++++++++ 18 files changed, 974 insertions(+) create mode 100644 api/v1beta1/upgradelog_types.go create mode 100644 config/crd/bases/management.harvesterhci.io_upgradelogs.yaml create mode 100644 config/rbac/upgradelog_admin_role.yaml create mode 100644 config/rbac/upgradelog_editor_role.yaml create mode 100644 config/rbac/upgradelog_viewer_role.yaml create mode 100644 config/samples/management_v1beta1_upgradelog.yaml create mode 100644 internal/controller/upgradelog_controller.go create mode 100644 internal/controller/upgradelog_controller_test.go create mode 100644 internal/webhook/v1/pod_webhook.go create mode 100644 internal/webhook/v1/pod_webhook_test.go create mode 100644 internal/webhook/v1/webhook_suite_test.go diff --git a/PROJECT b/PROJECT index c558e81..87cfb3e 100644 --- a/PROJECT +++ b/PROJECT @@ -44,4 +44,22 @@ resources: kind: Node path: k8s.io/api/core/v1 version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: harvesterhci.io + group: management + kind: UpgradeLog + path: github.com/harvester/upgrade-toolkit/api/v1beta1 + version: v1beta1 +- core: true + group: core + kind: Pod + path: k8s.io/api/core/v1 + version: v1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 version: "3" diff --git a/api/v1beta1/upgradelog_types.go b/api/v1beta1/upgradelog_types.go new file mode 100644 index 0000000..34efe68 --- /dev/null +++ b/api/v1beta1/upgradelog_types.go @@ -0,0 +1,92 @@ +/* +Copyright 2026. + +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 v1beta1 + +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. + +// UpgradeLogSpec defines the desired state of UpgradeLog +type UpgradeLogSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + // The following markers will use OpenAPI v3 schema to validate the value + // More info: https://book.kubebuilder.io/reference/markers/crd-validation.html + + // foo is an example field of UpgradeLog. Edit upgradelog_types.go to remove/update + // +optional + Foo *string `json:"foo,omitempty"` +} + +// UpgradeLogStatus defines the observed state of UpgradeLog. +type UpgradeLogStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // For Kubernetes API conventions, see: + // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties + + // conditions represent the current state of the UpgradeLog resource. + // Each condition has a unique type and reflects the status of a specific aspect of the resource. + // + // Standard condition types include: + // - "Available": the resource is fully functional + // - "Progressing": the resource is being created or updated + // - "Degraded": the resource failed to reach or maintain its desired state + // + // The status of each condition is one of True, False, or Unknown. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// UpgradeLog is the Schema for the upgradelogs API +type UpgradeLog struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of UpgradeLog + // +required + Spec UpgradeLogSpec `json:"spec"` + + // status defines the observed state of UpgradeLog + // +optional + Status UpgradeLogStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// UpgradeLogList contains a list of UpgradeLog +type UpgradeLogList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []UpgradeLog `json:"items"` +} + +func init() { + SchemeBuilder.Register(&UpgradeLog{}, &UpgradeLogList{}) +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 0860773..52f1d01 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -95,6 +95,107 @@ func (in *ReleaseMetadata) DeepCopy() *ReleaseMetadata { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpgradeLog) DeepCopyInto(out *UpgradeLog) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradeLog. +func (in *UpgradeLog) DeepCopy() *UpgradeLog { + if in == nil { + return nil + } + out := new(UpgradeLog) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UpgradeLog) 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 *UpgradeLogList) DeepCopyInto(out *UpgradeLogList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]UpgradeLog, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradeLogList. +func (in *UpgradeLogList) DeepCopy() *UpgradeLogList { + if in == nil { + return nil + } + out := new(UpgradeLogList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UpgradeLogList) 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 *UpgradeLogSpec) DeepCopyInto(out *UpgradeLogSpec) { + *out = *in + if in.Foo != nil { + in, out := &in.Foo, &out.Foo + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradeLogSpec. +func (in *UpgradeLogSpec) DeepCopy() *UpgradeLogSpec { + if in == nil { + return nil + } + out := new(UpgradeLogSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpgradeLogStatus) DeepCopyInto(out *UpgradeLogStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradeLogStatus. +func (in *UpgradeLogStatus) DeepCopy() *UpgradeLogStatus { + if in == nil { + return nil + } + out := new(UpgradeLogStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpgradePlan) DeepCopyInto(out *UpgradePlan) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index d2df7e7..92f200a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -48,6 +48,7 @@ import ( managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" "github.com/harvester/upgrade-toolkit/internal/controller" + webhookv1 "github.com/harvester/upgrade-toolkit/internal/webhook/v1" webhookv1beta1 "github.com/harvester/upgrade-toolkit/internal/webhook/v1beta1" "github.com/harvester/upgrade-toolkit/pkg/preflight" // +kubebuilder:scaffold:imports @@ -323,6 +324,20 @@ func (c *ManagerCommand) Run() error { os.Exit(1) } } + if err := (&controller.UpgradeLogReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "UpgradeLog") + os.Exit(1) + } + // nolint:goconst + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err := webhookv1.SetupPodWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create webhook", "webhook", "Pod") + os.Exit(1) + } + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/management.harvesterhci.io_upgradelogs.yaml b/config/crd/bases/management.harvesterhci.io_upgradelogs.yaml new file mode 100644 index 0000000..de3db12 --- /dev/null +++ b/config/crd/bases/management.harvesterhci.io_upgradelogs.yaml @@ -0,0 +1,126 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: upgradelogs.management.harvesterhci.io +spec: + group: management.harvesterhci.io + names: + kind: UpgradeLog + listKind: UpgradeLogList + plural: upgradelogs + singular: upgradelog + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: UpgradeLog is the Schema for the upgradelogs API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of UpgradeLog + properties: + foo: + description: foo is an example field of UpgradeLog. Edit upgradelog_types.go + to remove/update + type: string + type: object + status: + description: status defines the observed state of UpgradeLog + properties: + conditions: + description: |- + conditions represent the current state of the UpgradeLog resource. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types include: + - "Available": the resource is fully functional + - "Progressing": the resource is being created or updated + - "Degraded": the resource failed to reach or maintain its desired state + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index a97bd1c..b1af558 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/management.harvesterhci.io_upgradeplans.yaml +- bases/management.harvesterhci.io_upgradelogs.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 98fafe6..805bf32 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -22,6 +22,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the upgrade-toolkit itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- upgradelog_admin_role.yaml +- upgradelog_editor_role.yaml +- upgradelog_viewer_role.yaml - upgradeplan_admin_role.yaml - upgradeplan_editor_role.yaml - upgradeplan_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 1dcca0e..8bed7bf 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -178,6 +178,7 @@ rules: - apiGroups: - management.harvesterhci.io resources: + - upgradelogs - upgradeplans verbs: - create @@ -190,12 +191,14 @@ rules: - apiGroups: - management.harvesterhci.io resources: + - upgradelogs/finalizers - upgradeplans/finalizers verbs: - update - apiGroups: - management.harvesterhci.io resources: + - upgradelogs/status - upgradeplans/status verbs: - get diff --git a/config/rbac/upgradelog_admin_role.yaml b/config/rbac/upgradelog_admin_role.yaml new file mode 100644 index 0000000..8be68f6 --- /dev/null +++ b/config/rbac/upgradelog_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project upgrade-toolkit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over management.harvesterhci.io. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: upgrade-toolkit + app.kubernetes.io/managed-by: kustomize + name: upgradelog-admin-role +rules: +- apiGroups: + - management.harvesterhci.io + resources: + - upgradelogs + verbs: + - '*' +- apiGroups: + - management.harvesterhci.io + resources: + - upgradelogs/status + verbs: + - get diff --git a/config/rbac/upgradelog_editor_role.yaml b/config/rbac/upgradelog_editor_role.yaml new file mode 100644 index 0000000..ce0a33b --- /dev/null +++ b/config/rbac/upgradelog_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project upgrade-toolkit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the management.harvesterhci.io. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: upgrade-toolkit + app.kubernetes.io/managed-by: kustomize + name: upgradelog-editor-role +rules: +- apiGroups: + - management.harvesterhci.io + resources: + - upgradelogs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - management.harvesterhci.io + resources: + - upgradelogs/status + verbs: + - get diff --git a/config/rbac/upgradelog_viewer_role.yaml b/config/rbac/upgradelog_viewer_role.yaml new file mode 100644 index 0000000..c79f91b --- /dev/null +++ b/config/rbac/upgradelog_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project upgrade-toolkit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to management.harvesterhci.io resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: upgrade-toolkit + app.kubernetes.io/managed-by: kustomize + name: upgradelog-viewer-role +rules: +- apiGroups: + - management.harvesterhci.io + resources: + - upgradelogs + verbs: + - get + - list + - watch +- apiGroups: + - management.harvesterhci.io + resources: + - upgradelogs/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index b0355ed..e00c500 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,4 +1,5 @@ ## Append samples of your project ## resources: - management_v1beta1_upgradeplan.yaml +- management_v1beta1_upgradelog.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/management_v1beta1_upgradelog.yaml b/config/samples/management_v1beta1_upgradelog.yaml new file mode 100644 index 0000000..165a1ee --- /dev/null +++ b/config/samples/management_v1beta1_upgradelog.yaml @@ -0,0 +1,9 @@ +apiVersion: management.harvesterhci.io/v1beta1 +kind: UpgradeLog +metadata: + labels: + app.kubernetes.io/name: upgrade-toolkit + app.kubernetes.io/managed-by: kustomize + name: upgradelog-sample +spec: + # TODO(user): Add fields here diff --git a/internal/controller/upgradelog_controller.go b/internal/controller/upgradelog_controller.go new file mode 100644 index 0000000..4f4f3fa --- /dev/null +++ b/internal/controller/upgradelog_controller.go @@ -0,0 +1,63 @@ +/* +Copyright 2026. + +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" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +// UpgradeLogReconciler reconciles a UpgradeLog object +type UpgradeLogReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs/finalizers,verbs=update + +// 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 UpgradeLog 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.23.3/pkg/reconcile +func (r *UpgradeLogReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + _ = logf.FromContext(ctx) + + // TODO(user): your logic here + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *UpgradeLogReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&managementv1beta1.UpgradeLog{}). + Named("upgradelog"). + Complete(r) +} diff --git a/internal/controller/upgradelog_controller_test.go b/internal/controller/upgradelog_controller_test.go new file mode 100644 index 0000000..cd92f93 --- /dev/null +++ b/internal/controller/upgradelog_controller_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2026. + +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" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +var _ = Describe("UpgradeLog 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 + } + upgradelog := &managementv1beta1.UpgradeLog{} + + BeforeEach(func() { + By("creating the custom resource for the Kind UpgradeLog") + err := k8sClient.Get(ctx, typeNamespacedName, upgradelog) + if err != nil && errors.IsNotFound(err) { + resource := &managementv1beta1.UpgradeLog{ + 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 := &managementv1beta1.UpgradeLog{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance UpgradeLog") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &UpgradeLogReconciler{ + 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/webhook/v1/pod_webhook.go b/internal/webhook/v1/pod_webhook.go new file mode 100644 index 0000000..3cf2bee --- /dev/null +++ b/internal/webhook/v1/pod_webhook.go @@ -0,0 +1,119 @@ +/* +Copyright 2026. + +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 v1 + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// nolint:unused +// log is for logging in this package. +var podlog = logf.Log.WithName("pod-resource") + +// SetupPodWebhookWithManager registers the webhook for Pod in the manager. +func SetupPodWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&corev1.Pod{}). + WithValidator(&PodCustomValidator{}). + WithDefaulter(&PodCustomDefaulter{}). + Complete() +} + +// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! + +// +kubebuilder:webhook:path=/mutate--v1-pod,mutating=true,failurePolicy=fail,sideEffects=None,groups="",resources=pods,verbs=create;update,versions=v1,name=mpod-v1.kb.io,admissionReviewVersions=v1 + +// PodCustomDefaulter struct is responsible for setting default values on the custom resource of the +// Kind Pod when those are created or updated. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as it is used only for temporary operations and does not need to be deeply copied. +type PodCustomDefaulter struct { + // TODO(user): Add more fields as needed for defaulting +} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind Pod. +func (d *PodCustomDefaulter) Default(_ context.Context, obj runtime.Object) error { + pod, ok := obj.(*corev1.Pod) + if !ok { + return fmt.Errorf("expected a Pod object but got %T", obj) + } + podlog.Info("Defaulting for Pod", "name", pod.GetName()) + + // TODO(user): fill in your defaulting logic. + + return nil +} + +// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. +// NOTE: If you want to customise the 'path', use the flags '--defaulting-path' or '--validation-path'. +// +kubebuilder:webhook:path=/validate--v1-pod,mutating=false,failurePolicy=fail,sideEffects=None,groups="",resources=pods,verbs=create;update,versions=v1,name=vpod-v1.kb.io,admissionReviewVersions=v1 + +// PodCustomValidator struct is responsible for validating the Pod resource +// when it is created, updated, or deleted. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as this struct is used only for temporary operations and does not need to be deeply copied. +type PodCustomValidator struct { + // TODO(user): Add more fields as needed for validation +} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type Pod. +func (v *PodCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + pod, ok := obj.(*corev1.Pod) + if !ok { + return nil, fmt.Errorf("expected a Pod object but got %T", obj) + } + podlog.Info("Validation for Pod upon creation", "name", pod.GetName()) + + // TODO(user): fill in your validation logic upon object creation. + + return nil, nil +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type Pod. +func (v *PodCustomValidator) ValidateUpdate(_ context.Context, _, newObj runtime.Object) (admission.Warnings, error) { + newPod, ok := newObj.(*corev1.Pod) + if !ok { + return nil, fmt.Errorf("expected a Pod object but got %T", newObj) + } + podlog.Info("Validation for Pod upon update", "name", newPod.GetName()) + + // TODO(user): fill in your validation logic upon object update. + + return nil, nil +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type Pod. +func (v *PodCustomValidator) ValidateDelete(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + pod, ok := obj.(*corev1.Pod) + if !ok { + return nil, fmt.Errorf("expected a Pod object but got %T", obj) + } + podlog.Info("Validation for Pod upon deletion", "name", pod.GetName()) + + // TODO(user): fill in your validation logic upon object deletion. + + return nil, nil +} diff --git a/internal/webhook/v1/pod_webhook_test.go b/internal/webhook/v1/pod_webhook_test.go new file mode 100644 index 0000000..7f23e72 --- /dev/null +++ b/internal/webhook/v1/pod_webhook_test.go @@ -0,0 +1,86 @@ +/* +Copyright 2026. + +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 v1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + // TODO (user): Add any additional imports if needed +) + +var _ = Describe("Pod Webhook", func() { + var ( + obj *corev1.Pod + oldObj *corev1.Pod + validator PodCustomValidator + defaulter PodCustomDefaulter + ) + + BeforeEach(func() { + obj = &corev1.Pod{} + oldObj = &corev1.Pod{} + validator = PodCustomValidator{} + Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") + defaulter = PodCustomDefaulter{} + Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized") + Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + }) + + AfterEach(func() { + // TODO (user): Add any teardown logic common to all tests + }) + + Context("When creating Pod under Defaulting Webhook", func() { + // TODO (user): Add logic for defaulting webhooks + // Example: + // It("Should apply defaults when a required field is empty", func() { + // By("simulating a scenario where defaults should be applied") + // obj.SomeFieldWithDefault = "" + // By("calling the Default method to apply defaults") + // defaulter.Default(ctx, obj) + // By("checking that the default values are set") + // Expect(obj.SomeFieldWithDefault).To(Equal("default_value")) + // }) + }) + + Context("When creating or updating Pod under Validating Webhook", func() { + // TODO (user): Add logic for validating webhooks + // Example: + // It("Should deny creation if a required field is missing", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "" + // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) + // }) + // + // It("Should admit creation if all required fields are present", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "valid_value" + // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) + // }) + // + // It("Should validate updates correctly", func() { + // By("simulating a valid update scenario") + // oldObj.SomeRequiredField = "updated_value" + // obj.SomeRequiredField = "updated_value" + // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) + // }) + }) + +}) diff --git a/internal/webhook/v1/webhook_suite_test.go b/internal/webhook/v1/webhook_suite_test.go new file mode 100644 index 0000000..257afee --- /dev/null +++ b/internal/webhook/v1/webhook_suite_test.go @@ -0,0 +1,164 @@ +/* +Copyright 2026. + +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 v1 + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "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" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + // +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 ( + ctx context.Context + cancel context.CancelFunc + k8sClient client.Client + cfg *rest.Config + testEnv *envtest.Environment +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = corev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, + }, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager. + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + }), + LeaderElection: false, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = SetupPodWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready. + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) + Eventually(func() error { + conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + return err + } + + return conn.Close() + }).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + Eventually(func() error { + return testEnv.Stop() + }, time.Minute, time.Second).Should(Succeed()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} From d06746e7839547213ea5ff8ce09b10ec7131e68d Mon Sep 17 00:00:00 2001 From: Zespre Chang Date: Wed, 8 Apr 2026 22:35:37 +0800 Subject: [PATCH 2/6] feat(upgradelog): implement gRPC-based log collection subsystem Add a log collector/shipper architecture using gRPC and Protocol Buffers. Introduce the UpgradeLog CRD with a phase state machine and per-pod log tracking, a new pkg/upgradelog controller pipeline, CLI subcommands for the collector and shipper, and LogPreparing/LogPrepared phases in UpgradePlan. Signed-off-by: Zespre Chang --- Makefile | 19 ++ api/v1beta1/upgradelog_types.go | 133 +++++++-- api/v1beta1/upgradeplan_types.go | 2 + api/v1beta1/zz_generated.deepcopy.go | 29 +- cmd/log_collector.go | 78 +++++ cmd/log_shipper.go | 110 +++++++ cmd/main.go | 3 + ...anagement.harvesterhci.io_upgradelogs.yaml | 79 +++-- config/rbac/role.yaml | 10 + config/webhook/manifests.yaml | 19 ++ go.mod | 8 +- go.sum | 20 +- hack/generate-webhook-certs.sh | 3 + internal/controller/upgradelog_controller.go | 80 +++++- .../controller/upgradelog_controller_test.go | 35 ++- internal/webhook/v1/pod_webhook.go | 180 ++++++++---- pkg/logshipper/collector.go | 159 +++++++++++ pkg/logshipper/proto/logshipper.pb.go | 223 +++++++++++++++ pkg/logshipper/proto/logshipper.proto | 28 ++ pkg/logshipper/proto/logshipper_grpc.pb.go | 122 ++++++++ pkg/logshipper/shipper.go | 215 ++++++++++++++ pkg/logshipper/shipper_test.go | 246 ++++++++++++++++ pkg/upgradelog/constant.go | 32 +++ pkg/upgradelog/deps.go | 31 ++ pkg/upgradelog/phase.go | 28 ++ pkg/upgradelog/phase_collect.go | 34 +++ pkg/upgradelog/phase_collector_deploy.go | 270 ++++++++++++++++++ pkg/upgradelog/phase_stop.go | 64 +++++ pkg/upgradelog/pipeline.go | 161 +++++++++++ pkg/upgradeplan/phase_log_prepare.go | 71 +++++ pkg/upgradeplan/pipeline.go | 5 + 31 files changed, 2357 insertions(+), 140 deletions(-) create mode 100644 cmd/log_collector.go create mode 100644 cmd/log_shipper.go create mode 100644 pkg/logshipper/collector.go create mode 100644 pkg/logshipper/proto/logshipper.pb.go create mode 100644 pkg/logshipper/proto/logshipper.proto create mode 100644 pkg/logshipper/proto/logshipper_grpc.pb.go create mode 100644 pkg/logshipper/shipper.go create mode 100644 pkg/logshipper/shipper_test.go create mode 100644 pkg/upgradelog/constant.go create mode 100644 pkg/upgradelog/deps.go create mode 100644 pkg/upgradelog/phase.go create mode 100644 pkg/upgradelog/phase_collect.go create mode 100644 pkg/upgradelog/phase_collector_deploy.go create mode 100644 pkg/upgradelog/phase_stop.go create mode 100644 pkg/upgradelog/pipeline.go create mode 100644 pkg/upgradeplan/phase_log_prepare.go diff --git a/Makefile b/Makefile index 537932a..fa1686d 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,25 @@ lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes lint-config: golangci-lint ## Verify golangci-lint linter configuration $(GOLANGCI_LINT) config verify +##@ Code Generation + +PROTOC_GEN_GO ?= $(LOCALBIN)/protoc-gen-go +PROTOC_GEN_GO_GRPC ?= $(LOCALBIN)/protoc-gen-go-grpc +PROTOC_GEN_GO_VERSION ?= v1.36.6 +PROTOC_GEN_GO_GRPC_VERSION ?= v1.5.1 + +.PHONY: proto +proto: $(PROTOC_GEN_GO) $(PROTOC_GEN_GO_GRPC) ## Generate gRPC code from proto files. + protoc --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + pkg/logshipper/proto/logshipper.proto + +$(PROTOC_GEN_GO): $(LOCALBIN) + $(call go-install-tool,$(PROTOC_GEN_GO),google.golang.org/protobuf/cmd/protoc-gen-go,$(PROTOC_GEN_GO_VERSION)) + +$(PROTOC_GEN_GO_GRPC): $(LOCALBIN) + $(call go-install-tool,$(PROTOC_GEN_GO_GRPC),google.golang.org/grpc/cmd/protoc-gen-go-grpc,$(PROTOC_GEN_GO_GRPC_VERSION)) + ##@ Build .PHONY: build diff --git a/api/v1beta1/upgradelog_types.go b/api/v1beta1/upgradelog_types.go index 34efe68..9c9bcfa 100644 --- a/api/v1beta1/upgradelog_types.go +++ b/api/v1beta1/upgradelog_types.go @@ -17,51 +17,90 @@ limitations under the License. package v1beta1 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. +const ( + // UpgradeLog condition types + UpgradeLogCollectorReady string = "CollectorReady" +) -// UpgradeLogSpec defines the desired state of UpgradeLog -type UpgradeLogSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file - // The following markers will use OpenAPI v3 schema to validate the value - // More info: https://book.kubebuilder.io/reference/markers/crd-validation.html +// UpgradeLogPhase defines what overall phase the UpgradeLog is in. +type UpgradeLogPhase string - // foo is an example field of UpgradeLog. Edit upgradelog_types.go to remove/update - // +optional - Foo *string `json:"foo,omitempty"` +const ( + UpgradeLogPhaseCollectorDeploying UpgradeLogPhase = "CollectorDeploying" + UpgradeLogPhaseCollectorDeployed UpgradeLogPhase = "CollectorDeployed" + UpgradeLogPhaseCollecting UpgradeLogPhase = "Collecting" + UpgradeLogPhaseStopping UpgradeLogPhase = "Stopping" + UpgradeLogPhaseStopped UpgradeLogPhase = "Stopped" + UpgradeLogPhaseFailed UpgradeLogPhase = "Failed" +) + +// PodLogState represents the state of log collection for a single pod. +type PodLogState string + +const ( + PodLogStateStreaming PodLogState = "Streaming" + PodLogStateCompleted PodLogState = "Completed" + PodLogStateFailed PodLogState = "Failed" +) + +// UpgradeLogSpec defines the desired state of UpgradeLog. +type UpgradeLogSpec struct { + // upgradePlanName references the UpgradePlan this log collection belongs to. + // +required + // +kubebuilder:validation:MinLength=1 + UpgradePlanName string `json:"upgradePlanName"` } // UpgradeLogStatus defines the observed state of UpgradeLog. type UpgradeLogStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file - - // For Kubernetes API conventions, see: - // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties + // currentPhase tracks the UpgradeLog phase state machine. + CurrentPhase UpgradeLogPhase `json:"currentPhase,omitempty"` // conditions represent the current state of the UpgradeLog resource. - // Each condition has a unique type and reflects the status of a specific aspect of the resource. - // - // Standard condition types include: - // - "Available": the resource is fully functional - // - "Progressing": the resource is being created or updated - // - "Degraded": the resource failed to reach or maintain its desired state - // - // The status of each condition is one of True, False, or Unknown. // +listType=map // +listMapKey=type // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` + + // trackedPods maps pod names to their log collection state. + // +mapType=atomic + // +optional + TrackedPods map[string]PodLogStatus `json:"trackedPods,omitempty"` + + // storageUsage is the current PVC usage in bytes. + // +optional + StorageUsage int64 `json:"storageUsage,omitempty"` +} + +// PodLogStatus tracks log collection state for a single pod. +type PodLogStatus struct { + // component identifies the upgrade component (e.g., cluster-upgrade, node-upgrade, image-preload). + Component string `json:"component"` + // node is the node this pod ran on. + // +optional + Node string `json:"node,omitempty"` + // state is the current log collection state for this pod. + State PodLogState `json:"state"` + // bytesCollected is the total bytes written for this pod. + // +optional + BytesCollected int64 `json:"bytesCollected,omitempty"` + // logPath is the path within the PVC where logs are stored. + // +optional + LogPath string `json:"logPath,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,shortName=ul;uls +// +kubebuilder:printcolumn:name="UPGRADE_PLAN",type="string",JSONPath=`.spec.upgradePlanName` +// +kubebuilder:printcolumn:name="PHASE",type="string",JSONPath=`.status.currentPhase` +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=`.metadata.creationTimestamp` -// UpgradeLog is the Schema for the upgradelogs API +// UpgradeLog is the Schema for the upgradelogs API. type UpgradeLog struct { metav1.TypeMeta `json:",inline"` @@ -80,7 +119,7 @@ type UpgradeLog struct { // +kubebuilder:object:root=true -// UpgradeLogList contains a list of UpgradeLog +// UpgradeLogList contains a list of UpgradeLog. type UpgradeLogList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitzero"` @@ -90,3 +129,45 @@ type UpgradeLogList struct { func init() { SchemeBuilder.Register(&UpgradeLog{}, &UpgradeLogList{}) } + +func (u *UpgradeLog) SetCondition(conditionType string, conditionStatus metav1.ConditionStatus, reason string, message string) { + for i := range u.Status.Conditions { + if u.Status.Conditions[i].Type == conditionType { + u.Status.Conditions[i].Status = conditionStatus + u.Status.Conditions[i].Reason = reason + u.Status.Conditions[i].Message = message + u.Status.Conditions[i].LastTransitionTime = metav1.Now() + u.Status.Conditions[i].ObservedGeneration = u.Generation + return + } + } + u.Status.Conditions = append(u.Status.Conditions, metav1.Condition{ + Type: conditionType, + Status: conditionStatus, + LastTransitionTime: metav1.Now(), + Reason: reason, + Message: message, + ObservedGeneration: u.Generation, + }) +} + +func (u *UpgradeLog) ConditionTrue(conditionType string) bool { + for _, v := range u.Status.Conditions { + if v.Type == conditionType { + return v.Status == metav1.ConditionTrue + } + } + return false +} + +// ObjectReference returns a corev1.ObjectReference for this UpgradeLog +// suitable for use with an EventRecorder. +func (u *UpgradeLog) ObjectReference() *corev1.ObjectReference { + return &corev1.ObjectReference{ + Kind: "UpgradeLog", + APIVersion: GroupVersion.String(), + Name: u.Name, + UID: u.UID, + ResourceVersion: u.ResourceVersion, + } +} diff --git a/api/v1beta1/upgradeplan_types.go b/api/v1beta1/upgradeplan_types.go index fc84550..4d7f931 100644 --- a/api/v1beta1/upgradeplan_types.go +++ b/api/v1beta1/upgradeplan_types.go @@ -101,6 +101,8 @@ const ( // Overall UpgradePlan phases UpgradePlanPhaseInitializing UpgradePlanPhase = "Initializing" UpgradePlanPhaseInitialized UpgradePlanPhase = "Initialized" + UpgradePlanPhaseLogPreparing UpgradePlanPhase = "LogPreparing" + UpgradePlanPhaseLogPrepared UpgradePlanPhase = "LogPrepared" UpgradePlanPhaseISODownloading UpgradePlanPhase = "ISODownloading" UpgradePlanPhaseISODownloaded UpgradePlanPhase = "ISODownloaded" UpgradePlanPhaseRepoCreating UpgradePlanPhase = "RepoCreating" diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 52f1d01..375660d 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -80,6 +80,21 @@ func (in *NodeUpgradeStatus) DeepCopy() *NodeUpgradeStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodLogStatus) DeepCopyInto(out *PodLogStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodLogStatus. +func (in *PodLogStatus) DeepCopy() *PodLogStatus { + if in == nil { + return nil + } + out := new(PodLogStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ReleaseMetadata) DeepCopyInto(out *ReleaseMetadata) { *out = *in @@ -100,7 +115,7 @@ func (in *UpgradeLog) DeepCopyInto(out *UpgradeLog) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) + out.Spec = in.Spec in.Status.DeepCopyInto(&out.Status) } @@ -157,11 +172,6 @@ func (in *UpgradeLogList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpgradeLogSpec) DeepCopyInto(out *UpgradeLogSpec) { *out = *in - if in.Foo != nil { - in, out := &in.Foo, &out.Foo - *out = new(string) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradeLogSpec. @@ -184,6 +194,13 @@ func (in *UpgradeLogStatus) DeepCopyInto(out *UpgradeLogStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.TrackedPods != nil { + in, out := &in.TrackedPods, &out.TrackedPods + *out = make(map[string]PodLogStatus, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradeLogStatus. diff --git a/cmd/log_collector.go b/cmd/log_collector.go new file mode 100644 index 0000000..55eb650 --- /dev/null +++ b/cmd/log_collector.go @@ -0,0 +1,78 @@ +/* +Copyright 2026. + +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 main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/harvester/upgrade-toolkit/pkg/logshipper" +) + +// LogCollectorCommand implements the log-collector subcommand. +type LogCollectorCommand struct { + listen string + logDir string + upgradePlan string + fs *flag.FlagSet +} + +func (c *LogCollectorCommand) Name() string { + return "log-collector" +} + +func (c *LogCollectorCommand) FlagSet() *flag.FlagSet { + if c.fs == nil { + c.fs = flag.NewFlagSet("log-collector", flag.ExitOnError) + c.fs.StringVar(&c.listen, "listen", ":9500", "Address to listen on for gRPC connections.") + c.fs.StringVar(&c.logDir, "log-dir", "/logs", "Directory to write collected logs to.") + c.fs.StringVar(&c.upgradePlan, "upgrade-plan", "", "Name of the UpgradePlan this collector serves.") + } + return c.fs +} + +func (c *LogCollectorCommand) Run() error { + ctrl.SetLogger(zap.New()) + log := ctrl.Log.WithName("log-collector") + + if c.upgradePlan == "" { + return fmt.Errorf("--upgrade-plan is required") + } + + if err := os.MkdirAll(c.logDir, 0o755); err != nil { + return fmt.Errorf("creating log directory: %w", err) + } + + collector := logshipper.NewCollector(c.logDir, log) + + // Handle graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sigCh + log.Info("received shutdown signal, stopping collector") + collector.GracefulStop() + }() + + return collector.Serve(c.listen) +} diff --git a/cmd/log_shipper.go b/cmd/log_shipper.go new file mode 100644 index 0000000..5c1155a --- /dev/null +++ b/cmd/log_shipper.go @@ -0,0 +1,110 @@ +/* +Copyright 2026. + +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 main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/harvester/upgrade-toolkit/pkg/logshipper" +) + +// LogShipperCommand implements the log-shipper subcommand. +type LogShipperCommand struct { + logDir string + collectorEndpoint string + podName string + podNamespace string + component string + nodeName string + fs *flag.FlagSet +} + +func (c *LogShipperCommand) Name() string { + return "log-shipper" +} + +func (c *LogShipperCommand) FlagSet() *flag.FlagSet { + if c.fs == nil { + c.fs = flag.NewFlagSet("log-shipper", flag.ExitOnError) + c.fs.StringVar(&c.logDir, "log-dir", "/upgrade-log-shared", "Directory containing log files to ship.") + c.fs.StringVar(&c.collectorEndpoint, "collector-endpoint", "", "gRPC endpoint of the log collector service.") + c.fs.StringVar(&c.podName, "pod-name", "", "Name of the pod this shipper runs in.") + c.fs.StringVar(&c.podNamespace, "pod-namespace", "", "Namespace of the pod this shipper runs in.") + c.fs.StringVar(&c.component, "component", "", "Upgrade component label (e.g., cluster-upgrade, node-upgrade).") + c.fs.StringVar(&c.nodeName, "node-name", "", "Node name where this pod is running.") + } + return c.fs +} + +func (c *LogShipperCommand) Run() error { + ctrl.SetLogger(zap.New()) + log := ctrl.Log.WithName("log-shipper") + + if c.collectorEndpoint == "" { + return fmt.Errorf("--collector-endpoint is required") + } + + // Allow env vars as fallbacks for downward API values + if c.podName == "" { + c.podName = os.Getenv("POD_NAME") + } + if c.podNamespace == "" { + c.podNamespace = os.Getenv("POD_NAMESPACE") + } + if c.nodeName == "" { + c.nodeName = os.Getenv("NODE_NAME") + } + if c.component == "" { + c.component = os.Getenv("COMPONENT") + } + + config := logshipper.ShipperConfig{ + LogDir: c.logDir, + CollectorEndpoint: c.collectorEndpoint, + PodName: c.podName, + PodNamespace: c.podNamespace, + Component: c.component, + NodeName: c.nodeName, + } + + shipper := logshipper.NewShipper(config, log) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sigCh + log.Info("received shutdown signal, stopping shipper") + cancel() + }() + + if err := shipper.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} diff --git a/cmd/main.go b/cmd/main.go index 92f200a..47e2bb9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -84,6 +84,8 @@ var commands = []Command{ &VMLiveMigrateDetectorCommand{}, &VersionGuardCommand{}, &RestoreVMCommand{}, + &LogCollectorCommand{}, + &LogShipperCommand{}, &VersionCommand{}, } @@ -327,6 +329,7 @@ func (c *ManagerCommand) Run() error { if err := (&controller.UpgradeLogReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), + Log: logf.FromContext(ctx).WithName("upgrade-log-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "UpgradeLog") os.Exit(1) diff --git a/config/crd/bases/management.harvesterhci.io_upgradelogs.yaml b/config/crd/bases/management.harvesterhci.io_upgradelogs.yaml index de3db12..3ea3815 100644 --- a/config/crd/bases/management.harvesterhci.io_upgradelogs.yaml +++ b/config/crd/bases/management.harvesterhci.io_upgradelogs.yaml @@ -11,13 +11,26 @@ spec: kind: UpgradeLog listKind: UpgradeLogList plural: upgradelogs + shortNames: + - ul + - uls singular: upgradelog - scope: Namespaced + scope: Cluster versions: - - name: v1beta1 + - additionalPrinterColumns: + - jsonPath: .spec.upgradePlanName + name: UPGRADE_PLAN + type: string + - jsonPath: .status.currentPhase + name: PHASE + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1beta1 schema: openAPIV3Schema: - description: UpgradeLog is the Schema for the upgradelogs API + description: UpgradeLog is the Schema for the upgradelogs API. properties: apiVersion: description: |- @@ -39,25 +52,20 @@ spec: spec: description: spec defines the desired state of UpgradeLog properties: - foo: - description: foo is an example field of UpgradeLog. Edit upgradelog_types.go - to remove/update + upgradePlanName: + description: upgradePlanName references the UpgradePlan this log collection + belongs to. + minLength: 1 type: string + required: + - upgradePlanName type: object status: description: status defines the observed state of UpgradeLog properties: conditions: - description: |- - conditions represent the current state of the UpgradeLog resource. - Each condition has a unique type and reflects the status of a specific aspect of the resource. - - Standard condition types include: - - "Available": the resource is fully functional - - "Progressing": the resource is being created or updated - - "Degraded": the resource failed to reach or maintain its desired state - - The status of each condition is one of True, False, or Unknown. + description: conditions represent the current state of the UpgradeLog + resource. items: description: Condition contains details for one aspect of the current state of this API Resource. @@ -116,6 +124,45 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + currentPhase: + description: currentPhase tracks the UpgradeLog phase state machine. + type: string + storageUsage: + description: storageUsage is the current PVC usage in bytes. + format: int64 + type: integer + trackedPods: + additionalProperties: + description: PodLogStatus tracks log collection state for a single + pod. + properties: + bytesCollected: + description: bytesCollected is the total bytes written for this + pod. + format: int64 + type: integer + component: + description: component identifies the upgrade component (e.g., + cluster-upgrade, node-upgrade, image-preload). + type: string + logPath: + description: logPath is the path within the PVC where logs are + stored. + type: string + node: + description: node is the node this pod ran on. + type: string + state: + description: state is the current log collection state for this + pod. + type: string + required: + - component + - state + type: object + description: trackedPods maps pod names to their log collection state. + type: object + x-kubernetes-map-type: atomic type: object required: - spec diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 8bed7bf..06e987c 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -37,6 +37,16 @@ rules: - nodes/stats verbs: - get +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - create + - delete + - get + - list + - watch - apiGroups: - "" resources: diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 286b355..56133b7 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -4,6 +4,25 @@ kind: MutatingWebhookConfiguration metadata: name: mutating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate--v1-pod + failurePolicy: Ignore + name: mpod-v1.kb.io + rules: + - apiGroups: + - "" + apiVersions: + - v1 + operations: + - CREATE + resources: + - pods + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/go.mod b/go.mod index 5ef39f1..e27729f 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,8 @@ require ( github.com/rancher/wrangler/v3 v3.2.4 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.27.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v12.0.0+incompatible @@ -128,10 +130,8 @@ require ( golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.41.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 9cb0c50..844bf3b 100644 --- a/go.sum +++ b/go.sum @@ -693,23 +693,23 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -727,8 +727,8 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/hack/generate-webhook-certs.sh b/hack/generate-webhook-certs.sh index b8228e4..dd5407d 100755 --- a/hack/generate-webhook-certs.sh +++ b/hack/generate-webhook-certs.sh @@ -76,6 +76,9 @@ echo "Patching MutatingWebhookConfiguration ..." kubectl patch mutatingwebhookconfiguration upgrade-toolkit-mutating-webhook-configuration \ --type='json' \ -p="[{\"op\": \"add\", \"path\": \"/webhooks/0/clientConfig/caBundle\", \"value\": \"${CA_BUNDLE}\"}]" +kubectl patch mutatingwebhookconfiguration upgrade-toolkit-mutating-webhook-configuration \ + --type='json' \ + -p="[{\"op\": \"add\", \"path\": \"/webhooks/1/clientConfig/caBundle\", \"value\": \"${CA_BUNDLE}\"}]" echo "Patching ValidatingWebhookConfiguration ..." kubectl patch validatingwebhookconfiguration upgrade-toolkit-validating-webhook-configuration \ diff --git a/internal/controller/upgradelog_controller.go b/internal/controller/upgradelog_controller.go index 4f4f3fa..4a1f386 100644 --- a/internal/controller/upgradelog_controller.go +++ b/internal/controller/upgradelog_controller.go @@ -18,46 +18,98 @@ package controller import ( "context" + "fmt" + "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - logf "sigs.k8s.io/controller-runtime/pkg/log" managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" + "github.com/harvester/upgrade-toolkit/pkg/upgradelog" ) +// upgradeLogPipelineExecutor abstracts the upgrade log pipeline so that tests +// can inject lightweight fakes. +type upgradeLogPipelineExecutor interface { + Execute(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) (ctrl.Result, error) +} + // UpgradeLogReconciler reconciles a UpgradeLog object type UpgradeLogReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + Log logr.Logger + EventRecorder record.EventRecorder + + pipeline upgradeLogPipelineExecutor } // +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs/finalizers,verbs=update +// +kubebuilder:rbac:groups=core,resources=persistentvolumeclaims,verbs=get;list;watch;create;delete +// +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 -// 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 UpgradeLog 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.23.3/pkg/reconcile func (r *UpgradeLogReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = logf.FromContext(ctx) + log := r.Log.WithValues("upgradeLog", req.Name) + ctx = logr.NewContext(ctx, log) + + var upgradeLog managementv1beta1.UpgradeLog + if err := r.Get(ctx, req.NamespacedName, &upgradeLog); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("fetching UpgradeLog: %w", err) + } + + upgradeLogCopy := upgradeLog.DeepCopy() - // TODO(user): your logic here + if r.pipeline == nil { + return ctrl.Result{}, fmt.Errorf("pipeline not initialized; call SetupWithManager first") + } - return ctrl.Result{}, nil + result, err := r.pipeline.Execute(ctx, upgradeLogCopy) + + // Update status if changed + if statusErr := r.Status().Update(ctx, upgradeLogCopy); statusErr != nil { + if apierrors.IsConflict(statusErr) { + return ctrl.Result{RequeueAfter: upgradelog.RequeueAfterDuration}, nil + } + return ctrl.Result{}, fmt.Errorf("updating UpgradeLog status: %w", statusErr) + } + + if err != nil { + log.Error(err, "pipeline execution error") + } + + return result, err } // SetupWithManager sets up the controller with the Manager. func (r *UpgradeLogReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.EventRecorder == nil { + r.EventRecorder = mgr.GetEventRecorderFor("upgradelog-controller") + } + + deps := &upgradelog.PhaseDeps{ + Client: r.Client, + Scheme: r.Scheme, + Log: r.Log, + EventRecorder: r.EventRecorder, + } + r.pipeline = upgradelog.NewPipeline(deps) + return ctrl.NewControllerManagedBy(mgr). For(&managementv1beta1.UpgradeLog{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Owns(&corev1.PersistentVolumeClaim{}). Named("upgradelog"). Complete(r) } diff --git a/internal/controller/upgradelog_controller_test.go b/internal/controller/upgradelog_controller_test.go index cd92f93..5b18c01 100644 --- a/internal/controller/upgradelog_controller_test.go +++ b/internal/controller/upgradelog_controller_test.go @@ -19,10 +19,12 @@ package controller import ( "context" + "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/reconcile" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -30,35 +32,41 @@ import ( managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" ) +// fakeUpgradeLogPipeline is a no-op pipeline for testing. +type fakeUpgradeLogPipeline struct{} + +func (f *fakeUpgradeLogPipeline) Execute(_ context.Context, _ *managementv1beta1.UpgradeLog) (ctrl.Result, error) { + return ctrl.Result{}, nil +} + var _ = Describe("UpgradeLog Controller", func() { Context("When reconciling a resource", func() { - const resourceName = "test-resource" + const resourceName = "test-upgrade-log" ctx := context.Background() typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Name: resourceName, } - upgradelog := &managementv1beta1.UpgradeLog{} + upgradelogObj := &managementv1beta1.UpgradeLog{} BeforeEach(func() { By("creating the custom resource for the Kind UpgradeLog") - err := k8sClient.Get(ctx, typeNamespacedName, upgradelog) + err := k8sClient.Get(ctx, typeNamespacedName, upgradelogObj) if err != nil && errors.IsNotFound(err) { resource := &managementv1beta1.UpgradeLog{ ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", + Name: resourceName, + }, + Spec: managementv1beta1.UpgradeLogSpec{ + UpgradePlanName: "test-upgrade-plan", }, - // 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 := &managementv1beta1.UpgradeLog{} err := k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) @@ -66,19 +74,20 @@ var _ = Describe("UpgradeLog Controller", func() { By("Cleanup the specific resource instance UpgradeLog") Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) + It("should successfully reconcile the resource", func() { By("Reconciling the created resource") controllerReconciler := &UpgradeLogReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Log: logr.Discard(), + pipeline: &fakeUpgradeLogPipeline{}, } _, 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/webhook/v1/pod_webhook.go b/internal/webhook/v1/pod_webhook.go index 3cf2bee..57399ee 100644 --- a/internal/webhook/v1/pod_webhook.go +++ b/internal/webhook/v1/pod_webhook.go @@ -19,39 +19,36 @@ package v1 import ( "context" "fmt" + "strings" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/harvester/upgrade-toolkit/pkg/upgradelog" ) -// nolint:unused -// log is for logging in this package. var podlog = logf.Log.WithName("pod-resource") // SetupPodWebhookWithManager registers the webhook for Pod in the manager. func SetupPodWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr). For(&corev1.Pod{}). - WithValidator(&PodCustomValidator{}). WithDefaulter(&PodCustomDefaulter{}). Complete() } -// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! - -// +kubebuilder:webhook:path=/mutate--v1-pod,mutating=true,failurePolicy=fail,sideEffects=None,groups="",resources=pods,verbs=create;update,versions=v1,name=mpod-v1.kb.io,admissionReviewVersions=v1 +// +kubebuilder:webhook:path=/mutate--v1-pod,mutating=true,failurePolicy=ignore,sideEffects=None,groups="",resources=pods,verbs=create,versions=v1,name=mpod-v1.kb.io,admissionReviewVersions=v1 -// PodCustomDefaulter struct is responsible for setting default values on the custom resource of the -// Kind Pod when those are created or updated. +// PodCustomDefaulter injects a log-shipper native sidecar into upgrade-labeled pods. // // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, // as it is used only for temporary operations and does not need to be deeply copied. -type PodCustomDefaulter struct { - // TODO(user): Add more fields as needed for defaulting -} +type PodCustomDefaulter struct{} // Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind Pod. func (d *PodCustomDefaulter) Default(_ context.Context, obj runtime.Object) error { @@ -59,61 +56,142 @@ func (d *PodCustomDefaulter) Default(_ context.Context, obj runtime.Object) erro if !ok { return fmt.Errorf("expected a Pod object but got %T", obj) } - podlog.Info("Defaulting for Pod", "name", pod.GetName()) - // TODO(user): fill in your defaulting logic. + // Only mutate pods with the upgrade-plan label + upgradePlanName, hasLabel := pod.Labels[upgradePlanLabel] + if !hasLabel { + return nil + } - return nil -} + // Skip if already injected + for _, c := range pod.Spec.InitContainers { + if c.Name == upgradelog.LogShipperContainer { + return nil + } + } -// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. -// NOTE: If you want to customise the 'path', use the flags '--defaulting-path' or '--validation-path'. -// +kubebuilder:webhook:path=/validate--v1-pod,mutating=false,failurePolicy=fail,sideEffects=None,groups="",resources=pods,verbs=create;update,versions=v1,name=vpod-v1.kb.io,admissionReviewVersions=v1 + podlog.Info("injecting log-shipper sidecar", "pod", pod.Name, "upgradePlan", upgradePlanName) -// PodCustomValidator struct is responsible for validating the Pod resource -// when it is created, updated, or deleted. -// -// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, -// as this struct is used only for temporary operations and does not need to be deeply copied. -type PodCustomValidator struct { - // TODO(user): Add more fields as needed for validation -} + component := pod.Labels[upgradeComponentLabel] + upgradeLogName := upgradePlanName // UpgradeLog is named after the UpgradePlan + collectorEndpoint := upgradelog.CollectorServiceEndpoint(upgradeLogName) -// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type Pod. -func (v *PodCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { - pod, ok := obj.(*corev1.Pod) - if !ok { - return nil, fmt.Errorf("expected a Pod object but got %T", obj) + // Determine the image from the first container (same upgrade-toolkit image) + image := "" + if len(pod.Spec.Containers) > 0 { + image = pod.Spec.Containers[0].Image } - podlog.Info("Validation for Pod upon creation", "name", pod.GetName()) - // TODO(user): fill in your validation logic upon object creation. + // 1. Add shared emptyDir volume + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ + Name: upgradelog.SharedLogVolumeName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }) + + // 2. Wrap main container command with tee + for i := range pod.Spec.Containers { + c := &pod.Spec.Containers[i] + if len(c.Command) > 0 { + originalCmd := buildOriginalCommand(c.Command, c.Args) + c.Command = []string{"/bin/sh", "-c"} + c.Args = []string{ + fmt.Sprintf("%s 2>&1 | tee %s/%s; exit ${PIPESTATUS[0]}", + originalCmd, + upgradelog.SharedLogMountPath, + upgradelog.SharedLogFileName, + ), + } + } + c.VolumeMounts = append(c.VolumeMounts, corev1.VolumeMount{ + Name: upgradelog.SharedLogVolumeName, + MountPath: upgradelog.SharedLogMountPath, + }) + } - return nil, nil + // 3. Inject native sidecar (init container with restartPolicy: Always) + sidecar := corev1.Container{ + Name: upgradelog.LogShipperContainer, + RestartPolicy: ptr.To(corev1.ContainerRestartPolicyAlways), + Image: image, + Command: []string{"upgrade-toolkit", "log-shipper"}, + Args: []string{ + fmt.Sprintf("--log-dir=%s", upgradelog.SharedLogMountPath), + fmt.Sprintf("--collector-endpoint=%s", collectorEndpoint), + }, + Env: []corev1.EnvVar{ + { + Name: "POD_NAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}, + }, + }, + { + Name: "POD_NAMESPACE", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.namespace"}, + }, + }, + { + Name: "NODE_NAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, + }, + }, + { + Name: "COMPONENT", + Value: component, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: upgradelog.SharedLogVolumeName, + MountPath: upgradelog.SharedLogMountPath, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + }, + }, + } + + // Prepend the sidecar to initContainers (native sidecars must start before main) + pod.Spec.InitContainers = append([]corev1.Container{sidecar}, pod.Spec.InitContainers...) + + return nil } -// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type Pod. -func (v *PodCustomValidator) ValidateUpdate(_ context.Context, _, newObj runtime.Object) (admission.Warnings, error) { - newPod, ok := newObj.(*corev1.Pod) - if !ok { - return nil, fmt.Errorf("expected a Pod object but got %T", newObj) - } - podlog.Info("Validation for Pod upon update", "name", newPod.GetName()) +// PodCustomValidator is a no-op validator for Pod resources. +type PodCustomValidator struct{} - // TODO(user): fill in your validation logic upon object update. +func (v *PodCustomValidator) ValidateCreate(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} +func (v *PodCustomValidator) ValidateUpdate(_ context.Context, _, _ runtime.Object) (admission.Warnings, error) { return nil, nil } -// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type Pod. -func (v *PodCustomValidator) ValidateDelete(_ context.Context, obj runtime.Object) (admission.Warnings, error) { - pod, ok := obj.(*corev1.Pod) - if !ok { - return nil, fmt.Errorf("expected a Pod object but got %T", obj) - } - podlog.Info("Validation for Pod upon deletion", "name", pod.GetName()) +func (v *PodCustomValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} - // TODO(user): fill in your validation logic upon object deletion. +const ( + upgradePlanLabel = "management.harvesterhci.io/upgrade-plan" + upgradeComponentLabel = "management.harvesterhci.io/upgrade-component" +) - return nil, nil +// buildOriginalCommand reconstructs the original command string from command and args slices. +func buildOriginalCommand(command, args []string) string { + parts := make([]string, 0, len(command)+len(args)) + parts = append(parts, command...) + parts = append(parts, args...) + return strings.Join(parts, " ") } diff --git a/pkg/logshipper/collector.go b/pkg/logshipper/collector.go new file mode 100644 index 0000000..02298b8 --- /dev/null +++ b/pkg/logshipper/collector.go @@ -0,0 +1,159 @@ +package logshipper + +import ( + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + + "github.com/go-logr/logr" + "google.golang.org/grpc" + + pb "github.com/harvester/upgrade-toolkit/pkg/logshipper/proto" +) + +// Collector is a gRPC server that receives log streams and writes them to files. +type Collector struct { + pb.UnimplementedLogCollectorServer + + logDir string + log logr.Logger + + mu sync.Mutex + trackedPods map[string]*podTracker + server *grpc.Server + bytesWritten int64 +} + +type podTracker struct { + component string + node string + file *os.File + bytes int64 +} + +// NewCollector creates a new log collector that writes logs to the given directory. +func NewCollector(logDir string, log logr.Logger) *Collector { + return &Collector{ + logDir: logDir, + log: log.WithName("log-collector"), + trackedPods: make(map[string]*podTracker), + } +} + +// StreamLogs implements the LogCollector gRPC service. +func (c *Collector) StreamLogs(stream pb.LogCollector_StreamLogsServer) error { + var podKey string + var tracker *podTracker + + for { + entry, err := stream.Recv() + if err == io.EOF { + c.log.V(1).Info("stream ended", "pod", podKey) + if tracker != nil { + if syncErr := tracker.file.Sync(); syncErr != nil { + c.log.Error(syncErr, "failed to sync log file", "pod", podKey) + } + } + var totalBytes int64 + if tracker != nil { + totalBytes = tracker.bytes + } + return stream.SendAndClose(&pb.StreamLogsResponse{ + BytesReceived: totalBytes, + }) + } + if err != nil { + return fmt.Errorf("receiving log entry: %w", err) + } + + // Lazily initialize the tracker on first entry + if tracker == nil { + podKey = entry.PodNamespace + "/" + entry.PodName + var initErr error + tracker, initErr = c.getOrCreateTracker(entry) + if initErr != nil { + return fmt.Errorf("creating log file for pod %s: %w", podKey, initErr) + } + c.log.V(1).Info("started tracking pod", "pod", podKey, "component", entry.Component) + } + + n, writeErr := tracker.file.Write(entry.Line) + if writeErr != nil { + return fmt.Errorf("writing log for pod %s: %w", podKey, writeErr) + } + tracker.bytes += int64(n) + + c.mu.Lock() + c.bytesWritten += int64(n) + c.mu.Unlock() + } +} + +func (c *Collector) getOrCreateTracker(entry *pb.LogEntry) (*podTracker, error) { + key := entry.PodNamespace + "/" + entry.PodName + + c.mu.Lock() + defer c.mu.Unlock() + + if t, exists := c.trackedPods[key]; exists { + return t, nil + } + + // Create directory structure: // + dir := filepath.Join(c.logDir, entry.Component) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating log directory %s: %w", dir, err) + } + + logPath := filepath.Join(dir, entry.PodName+".log") + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("opening log file %s: %w", logPath, err) + } + + t := &podTracker{ + component: entry.Component, + node: entry.Node, + file: f, + } + c.trackedPods[key] = t + return t, nil +} + +// Serve starts the gRPC server on the given address. +func (c *Collector) Serve(addr string) error { + lis, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listening on %s: %w", addr, err) + } + + c.server = grpc.NewServer() + pb.RegisterLogCollectorServer(c.server, c) + + c.log.Info("starting gRPC log collector", "addr", addr) + return c.server.Serve(lis) +} + +// GracefulStop stops the gRPC server gracefully. +func (c *Collector) GracefulStop() { + if c.server != nil { + c.server.GracefulStop() + } + c.mu.Lock() + defer c.mu.Unlock() + for key, t := range c.trackedPods { + if err := t.file.Close(); err != nil { + c.log.Error(err, "failed to close log file", "pod", key) + } + } +} + +// TotalBytesWritten returns the total bytes written across all pods. +func (c *Collector) TotalBytesWritten() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.bytesWritten +} diff --git a/pkg/logshipper/proto/logshipper.pb.go b/pkg/logshipper/proto/logshipper.pb.go new file mode 100644 index 0000000..eb0d955 --- /dev/null +++ b/pkg/logshipper/proto/logshipper.pb.go @@ -0,0 +1,223 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc v7.34.1 +// source: pkg/logshipper/proto/logshipper.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// LogEntry represents a single log line with metadata. +type LogEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + PodName string `protobuf:"bytes,1,opt,name=pod_name,json=podName,proto3" json:"pod_name,omitempty"` + PodNamespace string `protobuf:"bytes,2,opt,name=pod_namespace,json=podNamespace,proto3" json:"pod_namespace,omitempty"` + // component identifies the upgrade component (e.g., cluster-upgrade, node-upgrade, image-preload). + Component string `protobuf:"bytes,3,opt,name=component,proto3" json:"component,omitempty"` + Node string `protobuf:"bytes,4,opt,name=node,proto3" json:"node,omitempty"` + Line []byte `protobuf:"bytes,5,opt,name=line,proto3" json:"line,omitempty"` + TimestampUnixNano int64 `protobuf:"varint,6,opt,name=timestamp_unix_nano,json=timestampUnixNano,proto3" json:"timestamp_unix_nano,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogEntry) Reset() { + *x = LogEntry{} + mi := &file_pkg_logshipper_proto_logshipper_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogEntry) ProtoMessage() {} + +func (x *LogEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_logshipper_proto_logshipper_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. +func (*LogEntry) Descriptor() ([]byte, []int) { + return file_pkg_logshipper_proto_logshipper_proto_rawDescGZIP(), []int{0} +} + +func (x *LogEntry) GetPodName() string { + if x != nil { + return x.PodName + } + return "" +} + +func (x *LogEntry) GetPodNamespace() string { + if x != nil { + return x.PodNamespace + } + return "" +} + +func (x *LogEntry) GetComponent() string { + if x != nil { + return x.Component + } + return "" +} + +func (x *LogEntry) GetNode() string { + if x != nil { + return x.Node + } + return "" +} + +func (x *LogEntry) GetLine() []byte { + if x != nil { + return x.Line + } + return nil +} + +func (x *LogEntry) GetTimestampUnixNano() int64 { + if x != nil { + return x.TimestampUnixNano + } + return 0 +} + +// StreamLogsResponse is returned when the client-side stream ends. +type StreamLogsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + BytesReceived int64 `protobuf:"varint,1,opt,name=bytes_received,json=bytesReceived,proto3" json:"bytes_received,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamLogsResponse) Reset() { + *x = StreamLogsResponse{} + mi := &file_pkg_logshipper_proto_logshipper_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamLogsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamLogsResponse) ProtoMessage() {} + +func (x *StreamLogsResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_logshipper_proto_logshipper_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamLogsResponse.ProtoReflect.Descriptor instead. +func (*StreamLogsResponse) Descriptor() ([]byte, []int) { + return file_pkg_logshipper_proto_logshipper_proto_rawDescGZIP(), []int{1} +} + +func (x *StreamLogsResponse) GetBytesReceived() int64 { + if x != nil { + return x.BytesReceived + } + return 0 +} + +var File_pkg_logshipper_proto_logshipper_proto protoreflect.FileDescriptor + +const file_pkg_logshipper_proto_logshipper_proto_rawDesc = "" + + "\n" + + "%pkg/logshipper/proto/logshipper.proto\x12\n" + + "logshipper\"\xc0\x01\n" + + "\bLogEntry\x12\x19\n" + + "\bpod_name\x18\x01 \x01(\tR\apodName\x12#\n" + + "\rpod_namespace\x18\x02 \x01(\tR\fpodNamespace\x12\x1c\n" + + "\tcomponent\x18\x03 \x01(\tR\tcomponent\x12\x12\n" + + "\x04node\x18\x04 \x01(\tR\x04node\x12\x12\n" + + "\x04line\x18\x05 \x01(\fR\x04line\x12.\n" + + "\x13timestamp_unix_nano\x18\x06 \x01(\x03R\x11timestampUnixNano\";\n" + + "\x12StreamLogsResponse\x12%\n" + + "\x0ebytes_received\x18\x01 \x01(\x03R\rbytesReceived2T\n" + + "\fLogCollector\x12D\n" + + "\n" + + "StreamLogs\x12\x14.logshipper.LogEntry\x1a\x1e.logshipper.StreamLogsResponse(\x01B;Z9github.com/harvester/upgrade-toolkit/pkg/logshipper/protob\x06proto3" + +var ( + file_pkg_logshipper_proto_logshipper_proto_rawDescOnce sync.Once + file_pkg_logshipper_proto_logshipper_proto_rawDescData []byte +) + +func file_pkg_logshipper_proto_logshipper_proto_rawDescGZIP() []byte { + file_pkg_logshipper_proto_logshipper_proto_rawDescOnce.Do(func() { + file_pkg_logshipper_proto_logshipper_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_pkg_logshipper_proto_logshipper_proto_rawDesc), len(file_pkg_logshipper_proto_logshipper_proto_rawDesc))) + }) + return file_pkg_logshipper_proto_logshipper_proto_rawDescData +} + +var file_pkg_logshipper_proto_logshipper_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pkg_logshipper_proto_logshipper_proto_goTypes = []any{ + (*LogEntry)(nil), // 0: logshipper.LogEntry + (*StreamLogsResponse)(nil), // 1: logshipper.StreamLogsResponse +} +var file_pkg_logshipper_proto_logshipper_proto_depIdxs = []int32{ + 0, // 0: logshipper.LogCollector.StreamLogs:input_type -> logshipper.LogEntry + 1, // 1: logshipper.LogCollector.StreamLogs:output_type -> logshipper.StreamLogsResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pkg_logshipper_proto_logshipper_proto_init() } +func file_pkg_logshipper_proto_logshipper_proto_init() { + if File_pkg_logshipper_proto_logshipper_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_pkg_logshipper_proto_logshipper_proto_rawDesc), len(file_pkg_logshipper_proto_logshipper_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pkg_logshipper_proto_logshipper_proto_goTypes, + DependencyIndexes: file_pkg_logshipper_proto_logshipper_proto_depIdxs, + MessageInfos: file_pkg_logshipper_proto_logshipper_proto_msgTypes, + }.Build() + File_pkg_logshipper_proto_logshipper_proto = out.File + file_pkg_logshipper_proto_logshipper_proto_goTypes = nil + file_pkg_logshipper_proto_logshipper_proto_depIdxs = nil +} diff --git a/pkg/logshipper/proto/logshipper.proto b/pkg/logshipper/proto/logshipper.proto new file mode 100644 index 0000000..ddda909 --- /dev/null +++ b/pkg/logshipper/proto/logshipper.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package logshipper; + +option go_package = "github.com/harvester/upgrade-toolkit/pkg/logshipper/proto"; + +// LogCollector receives log streams from sidecar log-shipper containers. +service LogCollector { + // StreamLogs accepts a stream of log entries from a single pod and + // returns a summary response when the stream ends. + rpc StreamLogs(stream LogEntry) returns (StreamLogsResponse); +} + +// LogEntry represents a single log line with metadata. +message LogEntry { + string pod_name = 1; + string pod_namespace = 2; + // component identifies the upgrade component (e.g., cluster-upgrade, node-upgrade, image-preload). + string component = 3; + string node = 4; + bytes line = 5; + int64 timestamp_unix_nano = 6; +} + +// StreamLogsResponse is returned when the client-side stream ends. +message StreamLogsResponse { + int64 bytes_received = 1; +} diff --git a/pkg/logshipper/proto/logshipper_grpc.pb.go b/pkg/logshipper/proto/logshipper_grpc.pb.go new file mode 100644 index 0000000..0c42dad --- /dev/null +++ b/pkg/logshipper/proto/logshipper_grpc.pb.go @@ -0,0 +1,122 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v7.34.1 +// source: pkg/logshipper/proto/logshipper.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + LogCollector_StreamLogs_FullMethodName = "/logshipper.LogCollector/StreamLogs" +) + +// LogCollectorClient is the client API for LogCollector service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// LogCollector receives log streams from sidecar log-shipper containers. +type LogCollectorClient interface { + // StreamLogs accepts a stream of log entries from a single pod and + // returns a summary response when the stream ends. + StreamLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[LogEntry, StreamLogsResponse], error) +} + +type logCollectorClient struct { + cc grpc.ClientConnInterface +} + +func NewLogCollectorClient(cc grpc.ClientConnInterface) LogCollectorClient { + return &logCollectorClient{cc} +} + +func (c *logCollectorClient) StreamLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[LogEntry, StreamLogsResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &LogCollector_ServiceDesc.Streams[0], LogCollector_StreamLogs_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[LogEntry, StreamLogsResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type LogCollector_StreamLogsClient = grpc.ClientStreamingClient[LogEntry, StreamLogsResponse] + +// LogCollectorServer is the server API for LogCollector service. +// All implementations must embed UnimplementedLogCollectorServer +// for forward compatibility. +// +// LogCollector receives log streams from sidecar log-shipper containers. +type LogCollectorServer interface { + // StreamLogs accepts a stream of log entries from a single pod and + // returns a summary response when the stream ends. + StreamLogs(grpc.ClientStreamingServer[LogEntry, StreamLogsResponse]) error + mustEmbedUnimplementedLogCollectorServer() +} + +// UnimplementedLogCollectorServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedLogCollectorServer struct{} + +func (UnimplementedLogCollectorServer) StreamLogs(grpc.ClientStreamingServer[LogEntry, StreamLogsResponse]) error { + return status.Errorf(codes.Unimplemented, "method StreamLogs not implemented") +} +func (UnimplementedLogCollectorServer) mustEmbedUnimplementedLogCollectorServer() {} +func (UnimplementedLogCollectorServer) testEmbeddedByValue() {} + +// UnsafeLogCollectorServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to LogCollectorServer will +// result in compilation errors. +type UnsafeLogCollectorServer interface { + mustEmbedUnimplementedLogCollectorServer() +} + +func RegisterLogCollectorServer(s grpc.ServiceRegistrar, srv LogCollectorServer) { + // If the following call pancis, it indicates UnimplementedLogCollectorServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&LogCollector_ServiceDesc, srv) +} + +func _LogCollector_StreamLogs_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(LogCollectorServer).StreamLogs(&grpc.GenericServerStream[LogEntry, StreamLogsResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type LogCollector_StreamLogsServer = grpc.ClientStreamingServer[LogEntry, StreamLogsResponse] + +// LogCollector_ServiceDesc is the grpc.ServiceDesc for LogCollector service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var LogCollector_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "logshipper.LogCollector", + HandlerType: (*LogCollectorServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamLogs", + Handler: _LogCollector_StreamLogs_Handler, + ClientStreams: true, + }, + }, + Metadata: "pkg/logshipper/proto/logshipper.proto", +} diff --git a/pkg/logshipper/shipper.go b/pkg/logshipper/shipper.go new file mode 100644 index 0000000..03bb525 --- /dev/null +++ b/pkg/logshipper/shipper.go @@ -0,0 +1,215 @@ +package logshipper + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/go-logr/logr" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + pb "github.com/harvester/upgrade-toolkit/pkg/logshipper/proto" +) + +const ( + initialBackoff = 500 * time.Millisecond + maxBackoff = 10 * time.Second + filePollDelay = 500 * time.Millisecond + fileWaitTick = 200 * time.Millisecond + maxLineSize = 1024 * 1024 // 1MB + initBufSize = 64 * 1024 // 64KB +) + +// ShipperConfig holds configuration for the log shipper. +type ShipperConfig struct { + LogDir string + CollectorEndpoint string + PodName string + PodNamespace string + Component string + NodeName string +} + +// Option configures the Shipper. +type Option func(*Shipper) + +// WithDialOptions appends gRPC dial options (useful for testing with bufconn). +func WithDialOptions(opts ...grpc.DialOption) Option { + return func(s *Shipper) { + s.dialOpts = append(s.dialOpts, opts...) + } +} + +// Shipper tails log files and streams them to a log collector via gRPC. +type Shipper struct { + config ShipperConfig + log logr.Logger + dialOpts []grpc.DialOption +} + +// NewShipper creates a new log shipper. +func NewShipper(config ShipperConfig, log logr.Logger, opts ...Option) *Shipper { + s := &Shipper{ + config: config, + log: log.WithName("log-shipper"), + } + for _, o := range opts { + o(s) + } + return s +} + +// Run starts the shipper. It blocks until the context is cancelled or the +// log file reaches EOF after the main container exits. +func (s *Shipper) Run(ctx context.Context) error { + logFile := filepath.Join(s.config.LogDir, "output.log") + + // Wait for the log file to appear (main container may not have started yet) + s.log.Info("waiting for log file", "path", logFile) + if err := s.waitForFile(ctx, logFile); err != nil { + return fmt.Errorf("waiting for log file: %w", err) + } + + // Open the file once; streamFrom seeks to the right offset on each attempt + f, err := os.Open(logFile) + if err != nil { + return fmt.Errorf("opening log file: %w", err) + } + defer f.Close() //nolint:errcheck // best-effort cleanup + + var offset int64 + backoff := initialBackoff + + for { + err := s.streamFrom(ctx, f, &offset) + if err == nil || ctx.Err() != nil { + return ctx.Err() + } + + s.log.Info("stream failed, retrying", "error", err, "backoff", backoff) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } +} + +// streamFrom performs a single streaming attempt starting from *offset in f. +// On success (context cancelled cleanly) it returns nil and commits the offset. +// On transient errors it returns the error without advancing *offset, so the +// caller can retry from the same position. +func (s *Shipper) streamFrom(ctx context.Context, f *os.File, offset *int64) error { + startOffset := *offset + if _, err := f.Seek(startOffset, io.SeekStart); err != nil { + return fmt.Errorf("seeking log file: %w", err) + } + + opts := make([]grpc.DialOption, 0, 1+len(s.dialOpts)) + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + opts = append(opts, s.dialOpts...) + + conn, err := grpc.NewClient(s.config.CollectorEndpoint, opts...) + if err != nil { + return fmt.Errorf("creating gRPC client: %w", err) + } + defer conn.Close() //nolint:errcheck // best-effort cleanup + + client := pb.NewLogCollectorClient(conn) + stream, err := client.StreamLogs(ctx) + if err != nil { + return fmt.Errorf("opening stream: %w", err) + } + + // Monitor for server-side errors asynchronously. In client-streaming, + // the server status/error arrives as the response message. RecvMsg + // blocks until the server sends it (normally after CloseAndRecv, or + // immediately if the server handler returns an error). + serverDone := make(chan error, 1) + go func() { + var resp pb.StreamLogsResponse + serverDone <- stream.RecvMsg(&resp) + }() + + // localOffset tracks how far we've sent within this stream attempt. + // It is only committed to *offset on clean shutdown. + localOffset := startOffset + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, initBufSize), maxLineSize) + + for { + for scanner.Scan() { + line := scanner.Bytes() + lineWithNL := make([]byte, len(line)+1) + copy(lineWithNL, line) + lineWithNL[len(line)] = '\n' + + entry := &pb.LogEntry{ + PodName: s.config.PodName, + PodNamespace: s.config.PodNamespace, + Component: s.config.Component, + Node: s.config.NodeName, + Line: lineWithNL, + TimestampUnixNano: time.Now().UnixNano(), + } + if sendErr := stream.Send(entry); sendErr != nil { + return fmt.Errorf("sending log entry: %w", sendErr) + } + localOffset += int64(len(line)) + 1 + } + if scanner.Err() != nil { + return fmt.Errorf("scanning log file: %w", scanner.Err()) + } + + // EOF reached. Check if the main container is still running by + // watching for further writes. Also watch for server-side errors + // so we can retry if the collector crashes. + select { + case <-ctx.Done(): + // Context cancelled (SIGTERM). Commit offset. + *offset = localOffset + return nil + case sErr := <-serverDone: + // Server closed the stream or returned an error. + return fmt.Errorf("stream closed by server: %w", sErr) + case <-time.After(filePollDelay): + info, statErr := f.Stat() + if statErr != nil { + *offset = localOffset + return nil + } + if info.Size() <= localOffset { + // File hasn't grown. Native sidecars receive SIGTERM after + // main container exits, so ctx.Done() will fire. + continue + } + // File has grown, re-create scanner from current position + scanner = bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, initBufSize), maxLineSize) + } + } +} + +func (s *Shipper) waitForFile(ctx context.Context, path string) error { + ticker := time.NewTicker(fileWaitTick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if _, err := os.Stat(path); err == nil { + return nil + } + } + } +} diff --git a/pkg/logshipper/shipper_test.go b/pkg/logshipper/shipper_test.go new file mode 100644 index 0000000..33a229d --- /dev/null +++ b/pkg/logshipper/shipper_test.go @@ -0,0 +1,246 @@ +package logshipper + +import ( + "context" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + pb "github.com/harvester/upgrade-toolkit/pkg/logshipper/proto" +) + +const bufSize = 1024 * 1024 + +// recordingCollector is a test gRPC server that records all received entries. +type recordingCollector struct { + pb.UnimplementedLogCollectorServer + + mu sync.Mutex + entries []*pb.LogEntry +} + +func (c *recordingCollector) StreamLogs(stream grpc.ClientStreamingServer[pb.LogEntry, pb.StreamLogsResponse]) error { + var totalBytes int64 + for { + entry, err := stream.Recv() + if err == io.EOF { + return stream.SendAndClose(&pb.StreamLogsResponse{BytesReceived: totalBytes}) + } + if err != nil { + return err + } + c.mu.Lock() + c.entries = append(c.entries, entry) + c.mu.Unlock() + totalBytes += int64(len(entry.Line)) + } +} + +func (c *recordingCollector) getEntries() []*pb.LogEntry { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]*pb.LogEntry, len(c.entries)) + copy(out, c.entries) + return out +} + +// failAfterNCollector records entries but fails the first stream after N entries. +// Subsequent streams succeed normally. +type failAfterNCollector struct { + pb.UnimplementedLogCollectorServer + + mu sync.Mutex + entries []*pb.LogEntry + streams int + failAfterEntries int +} + +func (c *failAfterNCollector) StreamLogs(stream grpc.ClientStreamingServer[pb.LogEntry, pb.StreamLogsResponse]) error { + c.mu.Lock() + c.streams++ + streamNum := c.streams + c.mu.Unlock() + + var count int + var totalBytes int64 + for { + entry, err := stream.Recv() + if err == io.EOF { + return stream.SendAndClose(&pb.StreamLogsResponse{BytesReceived: totalBytes}) + } + if err != nil { + return err + } + c.mu.Lock() + c.entries = append(c.entries, entry) + c.mu.Unlock() + totalBytes += int64(len(entry.Line)) + count++ + + if streamNum == 1 && count >= c.failAfterEntries { + return fmt.Errorf("simulated stream failure") + } + } +} + +func (c *failAfterNCollector) getEntries() []*pb.LogEntry { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]*pb.LogEntry, len(c.entries)) + copy(out, c.entries) + return out +} + +func startServer(t *testing.T, srv pb.LogCollectorServer) (*bufconn.Listener, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + s := grpc.NewServer() + pb.RegisterLogCollectorServer(s, srv) + go func() { _ = s.Serve(lis) }() + return lis, func() { s.Stop() } +} + +func shipperDialOpts(lis *bufconn.Listener) []Option { + return []Option{WithDialOptions( + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return lis.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + )} +} + +func writeLogFile(t *testing.T, dir string, lines []string) { + t.Helper() + f, err := os.Create(filepath.Join(dir, "output.log")) + require.NoError(t, err) + for _, line := range lines { + _, err := fmt.Fprintln(f, line) + require.NoError(t, err) + } + require.NoError(t, f.Close()) +} + +func TestShipper_StreamsAllLines(t *testing.T) { + tmpDir := t.TempDir() + lines := []string{"line one", "line two", "line three"} + writeLogFile(t, tmpDir, lines) + + collector := &recordingCollector{} + lis, cleanup := startServer(t, collector) + defer cleanup() + + shipper := NewShipper(ShipperConfig{ + LogDir: tmpDir, + CollectorEndpoint: "passthrough:///bufconn", + PodName: "test-pod", + PodNamespace: "test-ns", + Component: "test-component", + NodeName: "test-node", + }, logr.Discard(), shipperDialOpts(lis)...) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- shipper.Run(ctx) }() + + require.Eventually(t, func() bool { + return len(collector.getEntries()) == len(lines) + }, 5*time.Second, 50*time.Millisecond) + + cancel() + err := <-done + assert.ErrorIs(t, err, context.Canceled) + + entries := collector.getEntries() + require.Len(t, entries, len(lines)) + for i, entry := range entries { + assert.Equal(t, lines[i]+"\n", string(entry.Line)) + assert.Equal(t, "test-pod", entry.PodName) + assert.Equal(t, "test-ns", entry.PodNamespace) + assert.Equal(t, "test-component", entry.Component) + assert.Equal(t, "test-node", entry.Node) + } +} + +func TestShipper_RetryAfterMidStreamFailure(t *testing.T) { + tmpDir := t.TempDir() + // Use enough lines so the scanner is still scanning when the server error propagates + lines := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"} + writeLogFile(t, tmpDir, lines) + + // Server fails after receiving 2 entries on the first stream, then succeeds + collector := &failAfterNCollector{failAfterEntries: 2} + lis, cleanup := startServer(t, collector) + defer cleanup() + + shipper := NewShipper(ShipperConfig{ + LogDir: tmpDir, + CollectorEndpoint: "passthrough:///bufconn", + PodName: "retry-pod", + PodNamespace: "retry-ns", + Component: "retry-comp", + NodeName: "retry-node", + }, logr.Discard(), shipperDialOpts(lis)...) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- shipper.Run(ctx) }() + + // Wait until all unique lines have been received + require.Eventually(t, func() bool { + entries := collector.getEntries() + seen := make(map[string]bool) + for _, e := range entries { + seen[string(e.Line)] = true + } + for _, l := range lines { + if !seen[l+"\n"] { + return false + } + } + return true + }, 15*time.Second, 100*time.Millisecond) + + cancel() + err := <-done + assert.ErrorIs(t, err, context.Canceled) +} + +func TestShipper_RespectsContextCancellation(t *testing.T) { + tmpDir := t.TempDir() + writeLogFile(t, tmpDir, []string{"one"}) + + collector := &recordingCollector{} + lis, cleanup := startServer(t, collector) + defer cleanup() + + shipper := NewShipper(ShipperConfig{ + LogDir: tmpDir, + CollectorEndpoint: "passthrough:///bufconn", + PodName: "cancel-pod", + PodNamespace: "cancel-ns", + Component: "cancel-comp", + NodeName: "cancel-node", + }, logr.Discard(), shipperDialOpts(lis)...) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + start := time.Now() + err := shipper.Run(ctx) + elapsed := time.Since(start) + + assert.ErrorIs(t, err, context.Canceled) + assert.Less(t, elapsed, 2*time.Second) +} diff --git a/pkg/upgradelog/constant.go b/pkg/upgradelog/constant.go new file mode 100644 index 0000000..c128153 --- /dev/null +++ b/pkg/upgradelog/constant.go @@ -0,0 +1,32 @@ +package upgradelog + +import "time" + +const ( + RequeueAfterDuration = 1 * time.Second + + // Labels and annotations + AnnotationPrefix = "management.harvesterhci.io" + LabelPrefix = AnnotationPrefix + + // Label applied to collector resources owned by an UpgradeLog + UpgradeLogLabel = LabelPrefix + "/" + "upgrade-log" + + // Collector component names + CollectorComponent = "log-collector" + CollectorPortName = "grpc" + CollectorPort = 9500 + CollectorLogDir = "/logs" + CollectorPVCSize = "5Gi" + CollectorReplicas = 1 + CollectorImage = "rancher/harvester-upgrade-toolkit" + LogShipperContainer = "log-shipper" + + // Shared volume for log tee between main container and sidecar + SharedLogVolumeName = "upgrade-log-shared" + SharedLogMountPath = "/upgrade-log-shared" + SharedLogFileName = "output.log" + + // Finalizer for UpgradeLog cleanup on deletion + UpgradeLogFinalizer = AnnotationPrefix + "/" + "upgradelog-cleanup" +) diff --git a/pkg/upgradelog/deps.go b/pkg/upgradelog/deps.go new file mode 100644 index 0000000..55613a3 --- /dev/null +++ b/pkg/upgradelog/deps.go @@ -0,0 +1,31 @@ +package upgradelog + +import ( + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +// PhaseDeps holds shared dependencies injected into all UpgradeLog phases. +type PhaseDeps struct { + Client client.Client + Scheme *runtime.Scheme + Log logr.Logger + + // EventRecorder emits Kubernetes Events on UpgradeLog resources. + EventRecorder record.EventRecorder +} + +// RecordEvent emits a Kubernetes Event on the given UpgradeLog. +// It is a no-op when the EventRecorder has not been set. +func (d *PhaseDeps) RecordEvent( + upgradeLog *managementv1beta1.UpgradeLog, + eventType, reason, message string, +) { + if d != nil && d.EventRecorder != nil { + d.EventRecorder.Event(upgradeLog.ObjectReference(), eventType, reason, message) + } +} diff --git a/pkg/upgradelog/phase.go b/pkg/upgradelog/phase.go new file mode 100644 index 0000000..03da5d0 --- /dev/null +++ b/pkg/upgradelog/phase.go @@ -0,0 +1,28 @@ +package upgradelog + +import ( + "context" + + ctrl "sigs.k8s.io/controller-runtime" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +// Runnable is the core phase interface. Run() is called on every reconcile +// loop while the phase is active. Implementations MUST be idempotent. +type Runnable interface { + Run(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) (ctrl.Result, error) + Name() string +} + +// PreRunnable is optionally implemented by phases that need setup before +// Run() on each reconcile. Implementations MUST be idempotent. +type PreRunnable interface { + PreRun(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) error +} + +// PostRunnable is optionally implemented by phases that need teardown after +// Run() signals completion. Implementations MUST be idempotent. +type PostRunnable interface { + PostRun(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) error +} diff --git a/pkg/upgradelog/phase_collect.go b/pkg/upgradelog/phase_collect.go new file mode 100644 index 0000000..e3b1abe --- /dev/null +++ b/pkg/upgradelog/phase_collect.go @@ -0,0 +1,34 @@ +package upgradelog + +import ( + "context" + + "github.com/go-logr/logr" + ctrl "sigs.k8s.io/controller-runtime" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +// CollectPhase is the steady-state phase where the collector is running +// and sidecars are streaming logs. It remains in this phase until the +// UpgradePlan reaches a terminal state, at which point the controller +// transitions the UpgradeLog to the Stopping phase externally. +type CollectPhase struct { + *PhaseDeps +} + +func NewCollectPhase(deps *PhaseDeps) *CollectPhase { + return &CollectPhase{PhaseDeps: deps} +} + +func (p *CollectPhase) Name() string { return "Collect" } + +func (p *CollectPhase) Run(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) (ctrl.Result, error) { + log := logr.FromContextOrDiscard(ctx) + log.V(2).Info("collecting logs, waiting for external stop signal") + + // This phase is a no-op on each reconcile. The transition to Stopping + // is triggered by the UpgradePlan controller when the upgrade reaches + // a terminal state (Succeeded or Failed). + return ctrl.Result{}, nil +} diff --git a/pkg/upgradelog/phase_collector_deploy.go b/pkg/upgradelog/phase_collector_deploy.go new file mode 100644 index 0000000..83a6c6e --- /dev/null +++ b/pkg/upgradelog/phase_collector_deploy.go @@ -0,0 +1,270 @@ +package upgradelog + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +const ( + collectorNamespace = "harvester-system" +) + +// CollectorDeployPhase creates the PVC, Deployment, and Service for the log collector. +type CollectorDeployPhase struct { + *PhaseDeps +} + +func NewCollectorDeployPhase(deps *PhaseDeps) *CollectorDeployPhase { + return &CollectorDeployPhase{PhaseDeps: deps} +} + +func (p *CollectorDeployPhase) Name() string { return "CollectorDeploy" } + +func (p *CollectorDeployPhase) Run(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) (ctrl.Result, error) { + log := logr.FromContextOrDiscard(ctx) + + collectorName := collectorDeploymentName(upgradeLog.Name) + + // Ensure PVC exists + if err := p.ensurePVC(ctx, upgradeLog); err != nil { + return ctrl.Result{}, fmt.Errorf("ensuring PVC: %w", err) + } + + // Ensure Deployment exists + if err := p.ensureDeployment(ctx, upgradeLog); err != nil { + return ctrl.Result{}, fmt.Errorf("ensuring Deployment: %w", err) + } + + // Ensure Service exists + if err := p.ensureService(ctx, upgradeLog); err != nil { + return ctrl.Result{}, fmt.Errorf("ensuring Service: %w", err) + } + + // Check if deployment is ready + var deploy appsv1.Deployment + if err := p.Client.Get(ctx, types.NamespacedName{ + Name: collectorName, + Namespace: collectorNamespace, + }, &deploy); err != nil { + return ctrl.Result{}, fmt.Errorf("getting collector deployment: %w", err) + } + + if deploy.Status.ReadyReplicas > 0 { + log.V(1).Info("collector deployment is ready") + upgradeLog.SetCondition( + managementv1beta1.UpgradeLogCollectorReady, + metav1.ConditionTrue, + "CollectorReady", + "Log collector deployment is ready", + ) + upgradeLog.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseCollectorDeployed + return ctrl.Result{}, nil + } + + log.V(1).Info("waiting for collector deployment to become ready") + return ctrl.Result{RequeueAfter: RequeueAfterDuration}, nil +} + +func (p *CollectorDeployPhase) ensurePVC(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) error { + pvcName := collectorPVCName(upgradeLog.Name) + var pvc corev1.PersistentVolumeClaim + err := p.Client.Get(ctx, types.NamespacedName{ + Name: pvcName, + Namespace: collectorNamespace, + }, &pvc) + if err == nil { + return nil // already exists + } + + pvc = corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: collectorNamespace, + Labels: map[string]string{ + UpgradeLogLabel: upgradeLog.Name, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse(CollectorPVCSize), + }, + }, + }, + } + + return p.Client.Create(ctx, &pvc) +} + +func (p *CollectorDeployPhase) ensureDeployment(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) error { + deployName := collectorDeploymentName(upgradeLog.Name) + var deploy appsv1.Deployment + err := p.Client.Get(ctx, types.NamespacedName{ + Name: deployName, + Namespace: collectorNamespace, + }, &deploy) + if err == nil { + return nil // already exists + } + + image := getCollectorImage(upgradeLog) + pvcName := collectorPVCName(upgradeLog.Name) + + deploy = appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deployName, + Namespace: collectorNamespace, + Labels: map[string]string{ + UpgradeLogLabel: upgradeLog.Name, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(CollectorReplicas)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + UpgradeLogLabel: upgradeLog.Name, + "app": CollectorComponent, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + UpgradeLogLabel: upgradeLog.Name, + "app": CollectorComponent, + }, + }, + Spec: corev1.PodSpec{ + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: ptr.To(int64(1000)), + }, + Containers: []corev1.Container{ + { + Name: CollectorComponent, + Image: image, + Command: []string{"upgrade-toolkit"}, + Args: []string{ + "log-collector", + fmt.Sprintf("--listen=:%d", CollectorPort), + fmt.Sprintf("--log-dir=%s", CollectorLogDir), + fmt.Sprintf("--upgrade-plan=%s", upgradeLog.Spec.UpgradePlanName), + }, + Ports: []corev1.ContainerPort{ + { + Name: CollectorPortName, + ContainerPort: int32(CollectorPort), + Protocol: corev1.ProtocolTCP, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "logs", + MountPath: CollectorLogDir, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "logs", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: pvcName, + }, + }, + }, + }, + }, + }, + }, + } + + return p.Client.Create(ctx, &deploy) +} + +func (p *CollectorDeployPhase) ensureService(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) error { + svcName := collectorServiceName(upgradeLog.Name) + var svc corev1.Service + err := p.Client.Get(ctx, types.NamespacedName{ + Name: svcName, + Namespace: collectorNamespace, + }, &svc) + if err == nil { + return nil // already exists + } + + svc = corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: svcName, + Namespace: collectorNamespace, + Labels: map[string]string{ + UpgradeLogLabel: upgradeLog.Name, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + UpgradeLogLabel: upgradeLog.Name, + "app": CollectorComponent, + }, + Ports: []corev1.ServicePort{ + { + Name: CollectorPortName, + Port: int32(CollectorPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + return p.Client.Create(ctx, &svc) +} + +// Naming helpers + +func collectorDeploymentName(upgradeLogName string) string { + return upgradeLogName + "-" + CollectorComponent +} + +func collectorServiceName(upgradeLogName string) string { + return upgradeLogName + "-" + CollectorComponent +} + +func collectorPVCName(upgradeLogName string) string { + return upgradeLogName + "-logs" +} + +// CollectorServiceEndpoint returns the in-cluster DNS endpoint for the collector service. +func CollectorServiceEndpoint(upgradeLogName string) string { + return fmt.Sprintf("%s.%s.svc:%d", + collectorServiceName(upgradeLogName), + collectorNamespace, + CollectorPort, + ) +} + +func getCollectorImage(upgradeLog *managementv1beta1.UpgradeLog) string { + // For now, use the default image. In the future, this could be + // configurable via an annotation on the UpgradeLog. + return CollectorImage + ":latest" +} diff --git a/pkg/upgradelog/phase_stop.go b/pkg/upgradelog/phase_stop.go new file mode 100644 index 0000000..0a8779a --- /dev/null +++ b/pkg/upgradelog/phase_stop.go @@ -0,0 +1,64 @@ +package upgradelog + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +// StopPhase gracefully shuts down the log collector. It scales the collector +// Deployment to zero and waits for it to terminate. The PVC is intentionally +// preserved for post-mortem access. +type StopPhase struct { + *PhaseDeps +} + +func NewStopPhase(deps *PhaseDeps) *StopPhase { + return &StopPhase{PhaseDeps: deps} +} + +func (p *StopPhase) Name() string { return "Stop" } + +func (p *StopPhase) Run(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) (ctrl.Result, error) { + log := logr.FromContextOrDiscard(ctx) + + deployName := collectorDeploymentName(upgradeLog.Name) + var deploy appsv1.Deployment + err := p.Client.Get(ctx, types.NamespacedName{ + Name: deployName, + Namespace: collectorNamespace, + }, &deploy) + if err != nil { + // Deployment already deleted or doesn't exist; consider stopped. + log.V(1).Info("collector deployment not found, marking as stopped") + upgradeLog.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseStopped + return ctrl.Result{}, nil + } + + // Scale to zero if not already + if deploy.Spec.Replicas == nil || *deploy.Spec.Replicas != 0 { + log.V(1).Info("scaling collector deployment to zero") + deploy.Spec.Replicas = ptr.To(int32(0)) + if updateErr := p.Client.Update(ctx, &deploy); updateErr != nil { + return ctrl.Result{}, fmt.Errorf("scaling collector deployment to zero: %w", updateErr) + } + return ctrl.Result{RequeueAfter: RequeueAfterDuration}, nil + } + + // Wait for all replicas to terminate + if deploy.Status.Replicas > 0 { + log.V(1).Info("waiting for collector pods to terminate", "replicas", deploy.Status.Replicas) + return ctrl.Result{RequeueAfter: RequeueAfterDuration}, nil + } + + log.V(1).Info("collector deployment stopped") + upgradeLog.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseStopped + return ctrl.Result{}, nil +} diff --git a/pkg/upgradelog/pipeline.go b/pkg/upgradelog/pipeline.go new file mode 100644 index 0000000..bf8810c --- /dev/null +++ b/pkg/upgradelog/pipeline.go @@ -0,0 +1,161 @@ +package upgradelog + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +// PhaseEntry ties a Runnable to its API phase constants. +type PhaseEntry struct { + Phase Runnable + ActivePhase managementv1beta1.UpgradeLogPhase + CompletedPhase managementv1beta1.UpgradeLogPhase +} + +// Pipeline is an ordered sequence of phases that drives the UpgradeLog lifecycle. +type Pipeline struct { + deps *PhaseDeps + phases []PhaseEntry + index map[managementv1beta1.UpgradeLogPhase]int +} + +// NewPipeline creates a new UpgradeLog pipeline with all phases wired up. +func NewPipeline(deps *PhaseDeps) *Pipeline { + p := &Pipeline{ + deps: deps, + phases: []PhaseEntry{ + { + NewCollectorDeployPhase(deps), + managementv1beta1.UpgradeLogPhaseCollectorDeploying, + managementv1beta1.UpgradeLogPhaseCollectorDeployed, + }, + { + NewCollectPhase(deps), + managementv1beta1.UpgradeLogPhaseCollecting, + // Collecting has no completed phase; it transitions externally + // when the UpgradePlan reaches a terminal state. + managementv1beta1.UpgradeLogPhase(""), + }, + { + NewStopPhase(deps), + managementv1beta1.UpgradeLogPhaseStopping, + managementv1beta1.UpgradeLogPhaseStopped, + }, + }, + } + p.buildIndex() + return p +} + +func (p *Pipeline) buildIndex() { + p.index = make(map[managementv1beta1.UpgradeLogPhase]int, len(p.phases)*2) + for i, entry := range p.phases { + p.index[entry.ActivePhase] = i + if entry.CompletedPhase != "" { + p.index[entry.CompletedPhase] = i + } + } +} + +// contextWithLog returns a context enriched with a logger that includes +// upgradeLog and phase as structured fields. +func (p *Pipeline) contextWithLog(ctx context.Context, upgradeLog string, phase string) context.Context { + var log logr.Logger + if p.deps != nil { + log = p.deps.Log + } else { + log = logr.Discard() + } + log = log.WithValues("upgradeLog", upgradeLog, "phase", phase) + return logr.NewContext(ctx, log) +} + +// Execute dispatches to the correct phase based on the UpgradeLog's current phase. +func (p *Pipeline) Execute( + ctx context.Context, + upgradeLog *managementv1beta1.UpgradeLog, +) (ctrl.Result, error) { + currentPhase := upgradeLog.Status.CurrentPhase + + // First reconcile: enter first phase + if currentPhase == "" { + return p.enterPhase(ctx, upgradeLog, 0) + } + + // Terminal phases + if currentPhase == managementv1beta1.UpgradeLogPhaseStopped || + currentPhase == managementv1beta1.UpgradeLogPhaseFailed { + return ctrl.Result{}, nil + } + + // Look up current phase + idx, found := p.index[currentPhase] + if !found { + return ctrl.Result{}, fmt.Errorf("unknown phase: %s", currentPhase) + } + + entry := p.phases[idx] + ctx = p.contextWithLog(ctx, upgradeLog.Name, entry.Phase.Name()) + + // Completed phase: run PostRun if implemented, then advance + if entry.CompletedPhase != "" && currentPhase == entry.CompletedPhase { + if postRunnable, ok := entry.Phase.(PostRunnable); ok { + if err := postRunnable.PostRun(ctx, upgradeLog); err != nil { + return ctrl.Result{}, err + } + } + + p.recordEvent(upgradeLog, corev1.EventTypeNormal, "PhaseCompleted", + fmt.Sprintf("Completed phase %s", entry.Phase.Name())) + + nextIdx := idx + 1 + if nextIdx >= len(p.phases) { + return ctrl.Result{}, nil + } + return p.enterPhase(ctx, upgradeLog, nextIdx) + } + + // Active phase: run Run + return entry.Phase.Run(ctx, upgradeLog) +} + +func (p *Pipeline) enterPhase( + ctx context.Context, + upgradeLog *managementv1beta1.UpgradeLog, + idx int, +) (ctrl.Result, error) { + entry := p.phases[idx] + ctx = p.contextWithLog(ctx, upgradeLog.Name, entry.Phase.Name()) + + if preRunnable, ok := entry.Phase.(PreRunnable); ok { + if err := preRunnable.PreRun(ctx, upgradeLog); err != nil { + return ctrl.Result{}, err + } + } + + upgradeLog.Status.CurrentPhase = entry.ActivePhase + p.recordEvent(upgradeLog, corev1.EventTypeNormal, "PhaseTransition", + fmt.Sprintf("Entering phase %s", entry.Phase.Name())) + return ctrl.Result{RequeueAfter: RequeueAfterDuration}, nil +} + +// TransitionToStop moves the UpgradeLog from Collecting to Stopping. +// Called externally when the UpgradePlan reaches a terminal state. +func (p *Pipeline) TransitionToStop(upgradeLog *managementv1beta1.UpgradeLog) { + if upgradeLog.Status.CurrentPhase == managementv1beta1.UpgradeLogPhaseCollecting { + upgradeLog.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseStopping + } +} + +func (p *Pipeline) recordEvent( + upgradeLog *managementv1beta1.UpgradeLog, + eventType, reason, message string, +) { + p.deps.RecordEvent(upgradeLog, eventType, reason, message) +} diff --git a/pkg/upgradeplan/phase_log_prepare.go b/pkg/upgradeplan/phase_log_prepare.go new file mode 100644 index 0000000..4f5f16f --- /dev/null +++ b/pkg/upgradeplan/phase_log_prepare.go @@ -0,0 +1,71 @@ +package upgradeplan + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + apierrors "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/controller/controllerutil" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" +) + +// LogPreparePhase creates an UpgradeLog CR and waits for the log collector +// infrastructure to become ready before proceeding to the next phase. +type LogPreparePhase struct { + *PhaseDeps +} + +func NewLogPreparePhase(deps *PhaseDeps) *LogPreparePhase { + return &LogPreparePhase{PhaseDeps: deps} +} + +func (p *LogPreparePhase) Name() string { return "LogPrepare" } + +func (p *LogPreparePhase) Run(ctx context.Context, upgradePlan *managementv1beta1.UpgradePlan) (ctrl.Result, error) { + log := logr.FromContextOrDiscard(ctx) + + upgradeLogName := upgradePlan.Name + + // Get or create UpgradeLog CR + var upgradeLog managementv1beta1.UpgradeLog + err := p.Client.Get(ctx, types.NamespacedName{Name: upgradeLogName}, &upgradeLog) + if apierrors.IsNotFound(err) { + log.V(1).Info("creating UpgradeLog", "name", upgradeLogName) + upgradeLog = managementv1beta1.UpgradeLog{ + ObjectMeta: metav1.ObjectMeta{ + Name: upgradeLogName, + }, + Spec: managementv1beta1.UpgradeLogSpec{ + UpgradePlanName: upgradePlan.Name, + }, + } + + // Set UpgradePlan as the owner so the UpgradeLog is cleaned up on deletion + if setErr := controllerutil.SetOwnerReference(upgradePlan, &upgradeLog, p.Scheme); setErr != nil { + return ctrl.Result{}, fmt.Errorf("setting owner reference: %w", setErr) + } + + if createErr := p.Client.Create(ctx, &upgradeLog); createErr != nil { + return ctrl.Result{}, fmt.Errorf("creating UpgradeLog: %w", createErr) + } + return ctrl.Result{RequeueAfter: RequeueAfterDuration}, nil + } + if err != nil { + return ctrl.Result{}, fmt.Errorf("getting UpgradeLog: %w", err) + } + + // Check if collector is ready + if upgradeLog.ConditionTrue(managementv1beta1.UpgradeLogCollectorReady) { + log.V(1).Info("log collector is ready, advancing to next phase") + updateProgressingPhase(upgradePlan, managementv1beta1.UpgradePlanPhaseLogPrepared, "") + return ctrl.Result{}, nil + } + + log.V(1).Info("waiting for log collector to become ready", "upgradeLogPhase", upgradeLog.Status.CurrentPhase) + return ctrl.Result{RequeueAfter: RequeueAfterDuration}, nil +} diff --git a/pkg/upgradeplan/pipeline.go b/pkg/upgradeplan/pipeline.go index 856a890..abfcef6 100644 --- a/pkg/upgradeplan/pipeline.go +++ b/pkg/upgradeplan/pipeline.go @@ -34,6 +34,11 @@ func NewPipeline(deps *PhaseDeps) *Pipeline { init: NewInitPhase(deps), finalize: NewFinalizePhase(deps), phases: []PhaseEntry{ + { + NewLogPreparePhase(deps), + managementv1beta1.UpgradePlanPhaseLogPreparing, + managementv1beta1.UpgradePlanPhaseLogPrepared, + }, { NewISODownloadPhase(deps), managementv1beta1.UpgradePlanPhaseISODownloading, From 9baacc8651c0f1f6d40b772f4cd64c5d5643e5bb Mon Sep 17 00:00:00 2001 From: Zespre Chang Date: Thu, 9 Apr 2026 07:41:33 +0800 Subject: [PATCH 3/6] feat(upgradelog): derive collector image from annotation and build version Signed-off-by: Zespre Chang --- pkg/upgradelog/constant.go | 3 + pkg/upgradelog/phase_collector_deploy.go | 12 +++- pkg/upgradelog/phase_collector_deploy_test.go | 67 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 pkg/upgradelog/phase_collector_deploy_test.go diff --git a/pkg/upgradelog/constant.go b/pkg/upgradelog/constant.go index c128153..5f7a817 100644 --- a/pkg/upgradelog/constant.go +++ b/pkg/upgradelog/constant.go @@ -12,6 +12,9 @@ const ( // Label applied to collector resources owned by an UpgradeLog UpgradeLogLabel = LabelPrefix + "/" + "upgrade-log" + // Annotation to override the upgrade-toolkit image (repo+name, not tag) + AnnotationUpgradeToolkitImage = AnnotationPrefix + "/" + "upgrade-toolkit-image" + // Collector component names CollectorComponent = "log-collector" CollectorPortName = "grpc" diff --git a/pkg/upgradelog/phase_collector_deploy.go b/pkg/upgradelog/phase_collector_deploy.go index 83a6c6e..a0ab454 100644 --- a/pkg/upgradelog/phase_collector_deploy.go +++ b/pkg/upgradelog/phase_collector_deploy.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/go-logr/logr" + buildversion "github.com/harvester/upgrade-toolkit/pkg/version" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -264,7 +265,12 @@ func CollectorServiceEndpoint(upgradeLogName string) string { } func getCollectorImage(upgradeLog *managementv1beta1.UpgradeLog) string { - // For now, use the default image. In the future, this could be - // configurable via an annotation on the UpgradeLog. - return CollectorImage + ":latest" + repo := CollectorImage + if upgradeLog != nil { + if image, ok := upgradeLog.Annotations[AnnotationUpgradeToolkitImage]; ok && image != "" { + repo = image + } + } + + return fmt.Sprintf("%s:%s", repo, buildversion.Version) } diff --git a/pkg/upgradelog/phase_collector_deploy_test.go b/pkg/upgradelog/phase_collector_deploy_test.go new file mode 100644 index 0000000..5a6bd89 --- /dev/null +++ b/pkg/upgradelog/phase_collector_deploy_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2026. + +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 upgradelog + +import ( + "fmt" + "testing" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" + buildversion "github.com/harvester/upgrade-toolkit/pkg/version" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetCollectorImage(t *testing.T) { + defaultImage := fmt.Sprintf("%s:%s", CollectorImage, buildversion.Version) + + t.Run("nil upgradeLog returns default", func(t *testing.T) { + assert.Equal(t, defaultImage, getCollectorImage(nil)) + }) + + t.Run("no annotations returns default", func(t *testing.T) { + ul := &managementv1beta1.UpgradeLog{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + } + assert.Equal(t, defaultImage, getCollectorImage(ul)) + }) + + t.Run("empty annotation returns default", func(t *testing.T) { + ul := &managementv1beta1.UpgradeLog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Annotations: map[string]string{ + AnnotationUpgradeToolkitImage: "", + }, + }, + } + assert.Equal(t, defaultImage, getCollectorImage(ul)) + }) + + t.Run("annotation overrides repo", func(t *testing.T) { + ul := &managementv1beta1.UpgradeLog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Annotations: map[string]string{ + AnnotationUpgradeToolkitImage: "my-registry/harvester-upgrade-toolkit", + }, + }, + } + expected := fmt.Sprintf("my-registry/harvester-upgrade-toolkit:%s", buildversion.Version) + assert.Equal(t, expected, getCollectorImage(ul)) + }) +} From e4ce3167199ec6dcac0092aa675db5294d5bc132 Mon Sep 17 00:00:00 2001 From: Zespre Chang Date: Tue, 14 Apr 2026 20:56:32 +0800 Subject: [PATCH 4/6] feat(upgradelog): add log-viewer subcommand for real-time log tailing Replace the inline shell script in the log-viewer sidecar with a proper Go subcommand that recursively scans directories for .log files, tails each file, and prints lines to stdout prefixed with the relative path. Signed-off-by: Zespre Chang --- cmd/log_viewer.go | 74 ++++++ cmd/main.go | 1 + pkg/logviewer/viewer.go | 215 ++++++++++++++++++ pkg/logviewer/viewer_test.go | 193 ++++++++++++++++ pkg/upgradelog/constant.go | 1 + pkg/upgradelog/phase_collector_deploy.go | 23 ++ pkg/upgradelog/phase_collector_deploy_test.go | 85 +++++++ pkg/upgradeplan/phase_log_prepare.go | 8 + 8 files changed, 600 insertions(+) create mode 100644 cmd/log_viewer.go create mode 100644 pkg/logviewer/viewer.go create mode 100644 pkg/logviewer/viewer_test.go diff --git a/cmd/log_viewer.go b/cmd/log_viewer.go new file mode 100644 index 0000000..14dfb10 --- /dev/null +++ b/cmd/log_viewer.go @@ -0,0 +1,74 @@ +/* +Copyright 2026. + +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 main + +import ( + "context" + "errors" + "flag" + "fmt" + "os/signal" + "syscall" + "time" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/harvester/upgrade-toolkit/pkg/logviewer" +) + +// LogViewerCommand implements the log-viewer subcommand. +type LogViewerCommand struct { + scanInterval time.Duration + fs *flag.FlagSet +} + +func (c *LogViewerCommand) Name() string { + return "log-viewer" +} + +func (c *LogViewerCommand) FlagSet() *flag.FlagSet { + if c.fs == nil { + c.fs = flag.NewFlagSet("log-viewer", flag.ExitOnError) + c.fs.DurationVar(&c.scanInterval, "scan-interval", logviewer.DefaultScanInterval, + "How often to re-scan directories for new .log files.") + } + return c.fs +} + +func (c *LogViewerCommand) Run() error { + ctrl.SetLogger(zap.New()) + log := ctrl.Log.WithName("log-viewer") + + dirs := c.fs.Args() + if len(dirs) == 0 { + return fmt.Errorf("at least one directory path is required") + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + + v := logviewer.NewViewer(logviewer.Config{ + WatchDirs: dirs, + ScanInterval: c.scanInterval, + }, log) + + if err := v.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} diff --git a/cmd/main.go b/cmd/main.go index 47e2bb9..ca173eb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -86,6 +86,7 @@ var commands = []Command{ &RestoreVMCommand{}, &LogCollectorCommand{}, &LogShipperCommand{}, + &LogViewerCommand{}, &VersionCommand{}, } diff --git a/pkg/logviewer/viewer.go b/pkg/logviewer/viewer.go new file mode 100644 index 0000000..1745f63 --- /dev/null +++ b/pkg/logviewer/viewer.go @@ -0,0 +1,215 @@ +/* +Copyright 2026. + +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 logviewer + +import ( + "bufio" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-logr/logr" +) + +const ( + DefaultScanInterval = 5 * time.Second + filePollDelay = 500 * time.Millisecond + maxLineSize = 1024 * 1024 // 1MB + initBufSize = 64 * 1024 // 64KB +) + +// Config holds configuration for the log viewer. +type Config struct { + // WatchDirs are the root directories to scan for .log files. + WatchDirs []string + + // ScanInterval controls how often new files are discovered. + ScanInterval time.Duration + + // Output is the writer for tailed log lines. Defaults to os.Stdout. + Output io.Writer +} + +// Viewer watches directories for .log files and tails them to an output writer, +// prefixing each line with the file's relative path from its watch root. +type Viewer struct { + config Config + log logr.Logger + + mu sync.Mutex + tailing map[string]context.CancelFunc // absolute path -> cancel + + writeMu sync.Mutex // serializes writes to config.Output +} + +// NewViewer creates a Viewer. +func NewViewer(config Config, log logr.Logger) *Viewer { + if config.Output == nil { + config.Output = os.Stdout + } + if config.ScanInterval <= 0 { + config.ScanInterval = DefaultScanInterval + } + return &Viewer{ + config: config, + log: log.WithName("log-viewer"), + tailing: make(map[string]context.CancelFunc), + } +} + +// Run starts watching and tailing. It blocks until ctx is cancelled. +func (v *Viewer) Run(ctx context.Context) error { + var wg sync.WaitGroup + + // Initial scan + v.scan(ctx, &wg) + + ticker := time.NewTicker(v.config.ScanInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + v.mu.Lock() + for _, cancel := range v.tailing { + cancel() + } + v.mu.Unlock() + wg.Wait() + return nil + case <-ticker.C: + v.scan(ctx, &wg) + } + } +} + +func (v *Viewer) scan(ctx context.Context, wg *sync.WaitGroup) { + for _, dir := range v.config.WatchDirs { + files, err := ScanForLogFiles([]string{dir}) + if err != nil { + v.log.V(1).Info("scan error", "dir", dir, "error", err) + continue + } + for _, absPath := range files { + v.mu.Lock() + if _, ok := v.tailing[absPath]; ok { + v.mu.Unlock() + continue + } + fileCtx, cancel := context.WithCancel(ctx) + v.tailing[absPath] = cancel + v.mu.Unlock() + + wg.Add(1) + go v.tailFile(fileCtx, wg, dir, absPath) + } + } +} + +func (v *Viewer) tailFile(ctx context.Context, wg *sync.WaitGroup, watchRoot, absPath string) { + defer wg.Done() + + relPath, err := filepath.Rel(watchRoot, absPath) + if err != nil { + relPath = absPath + } + + f, err := os.Open(absPath) + if err != nil { + v.log.V(1).Info("failed to open file", "path", absPath, "error", err) + v.removeTailing(absPath) + return + } + defer f.Close() //nolint:errcheck + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, initBufSize), maxLineSize) + + for { + for scanner.Scan() { + v.writeMu.Lock() + _, _ = fmt.Fprintf(v.config.Output, "%s: %s\n", relPath, scanner.Text()) + v.writeMu.Unlock() + } + if scanner.Err() != nil { + v.log.V(1).Info("scanner error", "path", absPath, "error", scanner.Err()) + v.removeTailing(absPath) + return + } + + // EOF reached, poll for more data + select { + case <-ctx.Done(): + return + case <-time.After(filePollDelay): + info, err := os.Stat(absPath) + if err != nil { + // File removed + v.log.V(1).Info("file removed", "path", absPath) + v.removeTailing(absPath) + return + } + pos, _ := f.Seek(0, io.SeekCurrent) + if info.Size() <= pos { + continue + } + // File has grown, re-create scanner from current position + scanner = bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, initBufSize), maxLineSize) + } + } +} + +func (v *Viewer) removeTailing(absPath string) { + v.mu.Lock() + defer v.mu.Unlock() + if cancel, ok := v.tailing[absPath]; ok { + cancel() + delete(v.tailing, absPath) + } +} + +// ScanForLogFiles walks the given directories and returns absolute paths of all +// .log files found, including in nested subdirectories. +func ScanForLogFiles(dirs []string) ([]string, error) { + var result []string + for _, dir := range dirs { + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip inaccessible entries + } + if !d.IsDir() && strings.HasSuffix(d.Name(), ".log") { + abs, absErr := filepath.Abs(path) + if absErr != nil { + return nil + } + result = append(result, abs) + } + return nil + }) + if err != nil { + return result, fmt.Errorf("walking %s: %w", dir, err) + } + } + return result, nil +} diff --git a/pkg/logviewer/viewer_test.go b/pkg/logviewer/viewer_test.go new file mode 100644 index 0000000..e61373b --- /dev/null +++ b/pkg/logviewer/viewer_test.go @@ -0,0 +1,193 @@ +/* +Copyright 2026. + +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 logviewer + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScanForLogFiles(t *testing.T) { + dir := t.TempDir() + + // Create nested structure with .log and non-log files + require.NoError(t, os.MkdirAll(filepath.Join(dir, "component-a"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "component-b", "nested"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "component-a", "pod1.log"), []byte("line1\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "component-b", "pod2.log"), []byte("line2\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "component-b", "nested", "deep.log"), []byte("line3\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "component-a", "readme.txt"), []byte("ignore\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "root.log"), []byte("root\n"), 0o644)) + + files, err := ScanForLogFiles([]string{dir}) + require.NoError(t, err) + + assert.Len(t, files, 4, "should find exactly 4 .log files") + + for _, f := range files { + assert.True(t, strings.HasSuffix(f, ".log"), "all results should be .log files: %s", f) + assert.True(t, filepath.IsAbs(f), "all results should be absolute paths: %s", f) + } +} + +func TestScanForLogFiles_NonExistentDir(t *testing.T) { + files, err := ScanForLogFiles([]string{"/nonexistent/path"}) + require.NoError(t, err) + assert.Empty(t, files) +} + +func TestViewer_TailsExistingFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pre-drain"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pre-drain", "node1.log"), []byte("hello from node1\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pre-drain", "node2.log"), []byte("hello from node2\n"), 0o644)) + + var buf bytes.Buffer + v := NewViewer(Config{ + WatchDirs: []string{dir}, + ScanInterval: 100 * time.Millisecond, + Output: &buf, + }, logr.Discard()) + + ctx, cancel := context.WithCancel(context.Background()) + + var runErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + runErr = v.Run(ctx) + }() + + // Wait for output + require.Eventually(t, func() bool { + return strings.Contains(buf.String(), "node1") && strings.Contains(buf.String(), "node2") + }, 5*time.Second, 50*time.Millisecond) + + cancel() + wg.Wait() + assert.NoError(t, runErr) + + output := buf.String() + assert.Contains(t, output, "pre-drain/node1.log: hello from node1") + assert.Contains(t, output, "pre-drain/node2.log: hello from node2") +} + +func TestViewer_DiscoversDynamicFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "component"), 0o755)) + + var buf bytes.Buffer + v := NewViewer(Config{ + WatchDirs: []string{dir}, + ScanInterval: 200 * time.Millisecond, + Output: &buf, + }, logr.Discard()) + + ctx, cancel := context.WithCancel(context.Background()) + + var runErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + runErr = v.Run(ctx) + }() + + // Create file after viewer starts + time.Sleep(300 * time.Millisecond) + require.NoError(t, os.WriteFile(filepath.Join(dir, "component", "late.log"), []byte("dynamic line\n"), 0o644)) + + require.Eventually(t, func() bool { + return strings.Contains(buf.String(), "dynamic line") + }, 5*time.Second, 50*time.Millisecond) + + cancel() + wg.Wait() + assert.NoError(t, runErr) + assert.Contains(t, buf.String(), "component/late.log: dynamic line") +} + +func TestViewer_GracefulShutdown(t *testing.T) { + dir := t.TempDir() + + v := NewViewer(Config{ + WatchDirs: []string{dir}, + ScanInterval: 1 * time.Second, + Output: &bytes.Buffer{}, + }, logr.Discard()) + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + done <- v.Run(ctx) + }() + + cancel() + + select { + case err := <-done: + assert.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Run did not return within 2 seconds after context cancellation") + } +} + +func TestViewer_RelativePathPrefix(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "b"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "b", "deep.log"), []byte("nested\n"), 0o644)) + + var buf bytes.Buffer + v := NewViewer(Config{ + WatchDirs: []string{dir}, + ScanInterval: 100 * time.Millisecond, + Output: &buf, + }, logr.Discard()) + + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = v.Run(ctx) + }() + + require.Eventually(t, func() bool { + return strings.Contains(buf.String(), "nested") + }, 5*time.Second, 50*time.Millisecond) + + cancel() + wg.Wait() + + // Must use relative path, not absolute + output := buf.String() + assert.Contains(t, output, "a/b/deep.log: nested") + assert.NotContains(t, output, dir, "output should not contain absolute watch root path") +} diff --git a/pkg/upgradelog/constant.go b/pkg/upgradelog/constant.go index 5f7a817..657990c 100644 --- a/pkg/upgradelog/constant.go +++ b/pkg/upgradelog/constant.go @@ -24,6 +24,7 @@ const ( CollectorReplicas = 1 CollectorImage = "rancher/harvester-upgrade-toolkit" LogShipperContainer = "log-shipper" + LogViewerContainer = "log-viewer" // Shared volume for log tee between main container and sidecar SharedLogVolumeName = "upgrade-log-shared" diff --git a/pkg/upgradelog/phase_collector_deploy.go b/pkg/upgradelog/phase_collector_deploy.go index a0ab454..e9fb2e6 100644 --- a/pkg/upgradelog/phase_collector_deploy.go +++ b/pkg/upgradelog/phase_collector_deploy.go @@ -185,6 +185,29 @@ func (p *CollectorDeployPhase) ensureDeployment(ctx context.Context, upgradeLog }, }, }, + { + Name: LogViewerContainer, + Image: image, + Command: []string{"upgrade-toolkit"}, + Args: []string{"log-viewer", CollectorLogDir}, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "logs", + MountPath: CollectorLogDir, + ReadOnly: true, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("5m"), + corev1.ResourceMemory: resource.MustParse("8Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + }, + }, + }, }, Volumes: []corev1.Volume{ { diff --git a/pkg/upgradelog/phase_collector_deploy_test.go b/pkg/upgradelog/phase_collector_deploy_test.go index 5a6bd89..a8e88bf 100644 --- a/pkg/upgradelog/phase_collector_deploy_test.go +++ b/pkg/upgradelog/phase_collector_deploy_test.go @@ -17,13 +17,20 @@ limitations under the License. package upgradelog import ( + "context" "fmt" "testing" managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" buildversion "github.com/harvester/upgrade-toolkit/pkg/version" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func TestGetCollectorImage(t *testing.T) { @@ -65,3 +72,81 @@ func TestGetCollectorImage(t *testing.T) { assert.Equal(t, expected, getCollectorImage(ul)) }) } + +func TestEnsureDeployment_LogViewerContainer(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + + ul := &managementv1beta1.UpgradeLog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-upgrade", + }, + Spec: managementv1beta1.UpgradeLogSpec{ + UpgradePlanName: "my-plan", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + phase := &CollectorDeployPhase{ + PhaseDeps: &PhaseDeps{ + Client: fakeClient, + Scheme: scheme, + }, + } + + err := phase.ensureDeployment(context.Background(), ul) + require.NoError(t, err) + + var deploy appsv1.Deployment + err = fakeClient.Get(context.Background(), types.NamespacedName{ + Name: collectorDeploymentName(ul.Name), + Namespace: collectorNamespace, + }, &deploy) + require.NoError(t, err) + + containers := deploy.Spec.Template.Spec.Containers + + t.Run("has two containers", func(t *testing.T) { + assert.Len(t, containers, 2) + }) + + // Find the log-viewer container + var viewerIdx int + for i, c := range containers { + if c.Name == LogViewerContainer { + viewerIdx = i + break + } + } + vc := containers[viewerIdx] + + t.Run("name is log-viewer", func(t *testing.T) { + assert.Equal(t, LogViewerContainer, vc.Name) + }) + + t.Run("uses same image as collector", func(t *testing.T) { + assert.Equal(t, containers[0].Image, vc.Image) + }) + + t.Run("command is upgrade-toolkit", func(t *testing.T) { + assert.Equal(t, []string{"upgrade-toolkit"}, vc.Command) + }) + + t.Run("args invoke log-viewer subcommand", func(t *testing.T) { + assert.Equal(t, []string{"log-viewer", CollectorLogDir}, vc.Args) + }) + + t.Run("volume mount is read-only", func(t *testing.T) { + require.Len(t, vc.VolumeMounts, 1) + assert.Equal(t, "logs", vc.VolumeMounts[0].Name) + assert.Equal(t, CollectorLogDir, vc.VolumeMounts[0].MountPath) + assert.True(t, vc.VolumeMounts[0].ReadOnly) + }) + + t.Run("resources are minimal", func(t *testing.T) { + assert.Equal(t, resource.MustParse("5m"), vc.Resources.Requests["cpu"]) + assert.Equal(t, resource.MustParse("8Mi"), vc.Resources.Requests["memory"]) + assert.Equal(t, resource.MustParse("50m"), vc.Resources.Limits["cpu"]) + assert.Equal(t, resource.MustParse("32Mi"), vc.Resources.Limits["memory"]) + }) +} diff --git a/pkg/upgradeplan/phase_log_prepare.go b/pkg/upgradeplan/phase_log_prepare.go index 4f5f16f..236f4e6 100644 --- a/pkg/upgradeplan/phase_log_prepare.go +++ b/pkg/upgradeplan/phase_log_prepare.go @@ -45,6 +45,14 @@ func (p *LogPreparePhase) Run(ctx context.Context, upgradePlan *managementv1beta }, } + // Custom image, if specified + if customImage, ok := upgradePlan.Annotations[AnnotationUpgradeToolkitImage]; ok { + if upgradeLog.Annotations == nil { + upgradeLog.Annotations = make(map[string]string) + } + upgradeLog.Annotations[AnnotationUpgradeToolkitImage] = customImage + } + // Set UpgradePlan as the owner so the UpgradeLog is cleaned up on deletion if setErr := controllerutil.SetOwnerReference(upgradePlan, &upgradeLog, p.Scheme); setErr != nil { return ctrl.Result{}, fmt.Errorf("setting owner reference: %w", setErr) From 0a2e5c35b6c9bf13af68dcf012ecf3e254557291 Mon Sep 17 00:00:00 2001 From: Zespre Chang Date: Tue, 14 Apr 2026 23:17:13 +0800 Subject: [PATCH 5/6] feat(upgradelog): set controller references on sub-resources for cascading deletion Signed-off-by: Zespre Chang --- pkg/upgradelog/phase_collector_deploy.go | 13 +++ pkg/upgradelog/phase_collector_deploy_test.go | 100 ++++++++++++++++-- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/pkg/upgradelog/phase_collector_deploy.go b/pkg/upgradelog/phase_collector_deploy.go index e9fb2e6..01dc009 100644 --- a/pkg/upgradelog/phase_collector_deploy.go +++ b/pkg/upgradelog/phase_collector_deploy.go @@ -13,6 +13,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" ) @@ -106,6 +107,10 @@ func (p *CollectorDeployPhase) ensurePVC(ctx context.Context, upgradeLog *manage }, } + if err := controllerutil.SetControllerReference(upgradeLog, &pvc, p.Scheme); err != nil { + return fmt.Errorf("setting controller reference on PVC: %w", err) + } + return p.Client.Create(ctx, &pvc) } @@ -224,6 +229,10 @@ func (p *CollectorDeployPhase) ensureDeployment(ctx context.Context, upgradeLog }, } + if err := controllerutil.SetControllerReference(upgradeLog, &deploy, p.Scheme); err != nil { + return fmt.Errorf("setting controller reference on Deployment: %w", err) + } + return p.Client.Create(ctx, &deploy) } @@ -261,6 +270,10 @@ func (p *CollectorDeployPhase) ensureService(ctx context.Context, upgradeLog *ma }, } + if err := controllerutil.SetControllerReference(upgradeLog, &svc, p.Scheme); err != nil { + return fmt.Errorf("setting controller reference on Service: %w", err) + } + return p.Client.Create(ctx, &svc) } diff --git a/pkg/upgradelog/phase_collector_deploy_test.go b/pkg/upgradelog/phase_collector_deploy_test.go index a8e88bf..ac58426 100644 --- a/pkg/upgradelog/phase_collector_deploy_test.go +++ b/pkg/upgradelog/phase_collector_deploy_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -33,6 +34,38 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ) +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, managementv1beta1.AddToScheme(scheme)) + return scheme +} + +func newTestUpgradeLog() *managementv1beta1.UpgradeLog { + return &managementv1beta1.UpgradeLog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-upgrade", + UID: types.UID("test-uid-12345"), + }, + Spec: managementv1beta1.UpgradeLogSpec{ + UpgradePlanName: "my-plan", + }, + } +} + +func assertOwnerReference(t *testing.T, refs []metav1.OwnerReference, ul *managementv1beta1.UpgradeLog) { + t.Helper() + require.Len(t, refs, 1) + ref := refs[0] + assert.Equal(t, "UpgradeLog", ref.Kind) + assert.Equal(t, ul.Name, ref.Name) + assert.Equal(t, ul.UID, ref.UID) + require.NotNil(t, ref.Controller) + assert.True(t, *ref.Controller) +} + func TestGetCollectorImage(t *testing.T) { defaultImage := fmt.Sprintf("%s:%s", CollectorImage, buildversion.Version) @@ -74,17 +107,8 @@ func TestGetCollectorImage(t *testing.T) { } func TestEnsureDeployment_LogViewerContainer(t *testing.T) { - scheme := runtime.NewScheme() - require.NoError(t, appsv1.AddToScheme(scheme)) - - ul := &managementv1beta1.UpgradeLog{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-upgrade", - }, - Spec: managementv1beta1.UpgradeLogSpec{ - UpgradePlanName: "my-plan", - }, - } + scheme := newTestScheme(t) + ul := newTestUpgradeLog() fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() phase := &CollectorDeployPhase{ @@ -149,4 +173,58 @@ func TestEnsureDeployment_LogViewerContainer(t *testing.T) { assert.Equal(t, resource.MustParse("50m"), vc.Resources.Limits["cpu"]) assert.Equal(t, resource.MustParse("32Mi"), vc.Resources.Limits["memory"]) }) + + t.Run("has correct owner reference", func(t *testing.T) { + assertOwnerReference(t, deploy.OwnerReferences, ul) + }) +} + +func TestEnsurePVC_OwnerReference(t *testing.T) { + scheme := newTestScheme(t) + ul := newTestUpgradeLog() + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + phase := &CollectorDeployPhase{ + PhaseDeps: &PhaseDeps{ + Client: fakeClient, + Scheme: scheme, + }, + } + + err := phase.ensurePVC(context.Background(), ul) + require.NoError(t, err) + + var pvc corev1.PersistentVolumeClaim + err = fakeClient.Get(context.Background(), types.NamespacedName{ + Name: collectorPVCName(ul.Name), + Namespace: collectorNamespace, + }, &pvc) + require.NoError(t, err) + + assertOwnerReference(t, pvc.OwnerReferences, ul) +} + +func TestEnsureService_OwnerReference(t *testing.T) { + scheme := newTestScheme(t) + ul := newTestUpgradeLog() + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + phase := &CollectorDeployPhase{ + PhaseDeps: &PhaseDeps{ + Client: fakeClient, + Scheme: scheme, + }, + } + + err := phase.ensureService(context.Background(), ul) + require.NoError(t, err) + + var svc corev1.Service + err = fakeClient.Get(context.Background(), types.NamespacedName{ + Name: collectorServiceName(ul.Name), + Namespace: collectorNamespace, + }, &svc) + require.NoError(t, err) + + assertOwnerReference(t, svc.OwnerReferences, ul) } From f830bb20486b50bb931e9be12880c8cbebb0bab9 Mon Sep 17 00:00:00 2001 From: Zespre Chang Date: Thu, 16 Apr 2026 23:39:59 +0800 Subject: [PATCH 6/6] feat(upgradelog): stop log collection when UpgradePlan reaches terminal phase Signed-off-by: Zespre Chang --- api/v1beta1/upgradelog_types.go | 1 + internal/controller/upgradelog_controller.go | 39 ++++++ pkg/upgradelog/phase_collect.go | 34 ++++- pkg/upgradelog/phase_collect_test.go | 127 +++++++++++++++++++ pkg/upgradelog/pipeline.go | 12 +- 5 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 pkg/upgradelog/phase_collect_test.go diff --git a/api/v1beta1/upgradelog_types.go b/api/v1beta1/upgradelog_types.go index 9c9bcfa..9ce9ce8 100644 --- a/api/v1beta1/upgradelog_types.go +++ b/api/v1beta1/upgradelog_types.go @@ -33,6 +33,7 @@ const ( UpgradeLogPhaseCollectorDeploying UpgradeLogPhase = "CollectorDeploying" UpgradeLogPhaseCollectorDeployed UpgradeLogPhase = "CollectorDeployed" UpgradeLogPhaseCollecting UpgradeLogPhase = "Collecting" + UpgradeLogPhaseCollected UpgradeLogPhase = "Collected" UpgradeLogPhaseStopping UpgradeLogPhase = "Stopping" UpgradeLogPhaseStopped UpgradeLogPhase = "Stopped" UpgradeLogPhaseFailed UpgradeLogPhase = "Failed" diff --git a/internal/controller/upgradelog_controller.go b/internal/controller/upgradelog_controller.go index 4a1f386..0af6ebc 100644 --- a/internal/controller/upgradelog_controller.go +++ b/internal/controller/upgradelog_controller.go @@ -25,9 +25,15 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" "github.com/harvester/upgrade-toolkit/pkg/upgradelog" @@ -52,6 +58,7 @@ type UpgradeLogReconciler struct { // +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradelogs/finalizers,verbs=update +// +kubebuilder:rbac:groups=management.harvesterhci.io,resources=upgradeplans,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=persistentvolumeclaims,verbs=get;list;watch;create;delete // +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 @@ -110,6 +117,38 @@ func (r *UpgradeLogReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Owns(&corev1.PersistentVolumeClaim{}). + Watches( + &managementv1beta1.UpgradePlan{}, + handler.EnqueueRequestsFromMapFunc(mapUpgradePlanToUpgradeLog), + builder.WithPredicates(upgradePlanTerminalPredicate{}), + ). Named("upgradelog"). Complete(r) } + +func mapUpgradePlanToUpgradeLog(_ context.Context, obj client.Object) []reconcile.Request { + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{Name: obj.GetName()}}, + } +} + +type upgradePlanTerminalPredicate struct { + predicate.Funcs +} + +func (upgradePlanTerminalPredicate) Create(_ event.CreateEvent) bool { return false } +func (upgradePlanTerminalPredicate) Delete(_ event.DeleteEvent) bool { return true } +func (upgradePlanTerminalPredicate) Generic(_ event.GenericEvent) bool { return false } + +func (upgradePlanTerminalPredicate) Update(e event.UpdateEvent) bool { + newPlan, ok := e.ObjectNew.(*managementv1beta1.UpgradePlan) + if !ok { + return false + } + return isUpgradePlanTerminal(newPlan.Status.CurrentPhase) +} + +func isUpgradePlanTerminal(phase managementv1beta1.UpgradePlanPhase) bool { + return phase == managementv1beta1.UpgradePlanPhaseSucceeded || + phase == managementv1beta1.UpgradePlanPhaseFailed +} diff --git a/pkg/upgradelog/phase_collect.go b/pkg/upgradelog/phase_collect.go index e3b1abe..0fae927 100644 --- a/pkg/upgradelog/phase_collect.go +++ b/pkg/upgradelog/phase_collect.go @@ -2,17 +2,19 @@ package upgradelog import ( "context" + "fmt" "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" ) // CollectPhase is the steady-state phase where the collector is running -// and sidecars are streaming logs. It remains in this phase until the -// UpgradePlan reaches a terminal state, at which point the controller -// transitions the UpgradeLog to the Stopping phase externally. +// and sidecars are streaming logs. It completes when the associated +// UpgradePlan reaches a terminal state (Succeeded or Failed) or is deleted. type CollectPhase struct { *PhaseDeps } @@ -25,10 +27,28 @@ func (p *CollectPhase) Name() string { return "Collect" } func (p *CollectPhase) Run(ctx context.Context, upgradeLog *managementv1beta1.UpgradeLog) (ctrl.Result, error) { log := logr.FromContextOrDiscard(ctx) - log.V(2).Info("collecting logs, waiting for external stop signal") - // This phase is a no-op on each reconcile. The transition to Stopping - // is triggered by the UpgradePlan controller when the upgrade reaches - // a terminal state (Succeeded or Failed). + var upgradePlan managementv1beta1.UpgradePlan + if err := p.Client.Get(ctx, types.NamespacedName{Name: upgradeLog.Spec.UpgradePlanName}, &upgradePlan); err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("fetching UpgradePlan: %w", err) + } + log.V(1).Info("UpgradePlan not found, completing collection") + upgradeLog.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseCollected + return ctrl.Result{}, nil + } + + if isUpgradePlanTerminal(upgradePlan.Status.CurrentPhase) { + log.V(1).Info("UpgradePlan reached terminal phase, completing collection", "phase", upgradePlan.Status.CurrentPhase) + upgradeLog.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseCollected + return ctrl.Result{}, nil + } + + log.V(2).Info("collecting logs, UpgradePlan still active", "phase", upgradePlan.Status.CurrentPhase) return ctrl.Result{}, nil } + +func isUpgradePlanTerminal(phase managementv1beta1.UpgradePlanPhase) bool { + return phase == managementv1beta1.UpgradePlanPhaseSucceeded || + phase == managementv1beta1.UpgradePlanPhaseFailed +} diff --git a/pkg/upgradelog/phase_collect_test.go b/pkg/upgradelog/phase_collect_test.go new file mode 100644 index 0000000..4ee84d0 --- /dev/null +++ b/pkg/upgradelog/phase_collect_test.go @@ -0,0 +1,127 @@ +/* +Copyright 2026. + +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 upgradelog + +import ( + "context" + "testing" + + managementv1beta1 "github.com/harvester/upgrade-toolkit/api/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newTestUpgradePlan(name string, phase managementv1beta1.UpgradePlanPhase) *managementv1beta1.UpgradePlan { + up := &managementv1beta1.UpgradePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: managementv1beta1.UpgradePlanSpec{ + Version: ptr.To("v1.0.0"), + }, + } + up.Status.CurrentPhase = phase + return up +} + +func TestCollectPhase_Run(t *testing.T) { + t.Run("stays in Collecting when UpgradePlan is active", func(t *testing.T) { + scheme := newTestScheme(t) + upgradePlan := newTestUpgradePlan("my-plan", managementv1beta1.UpgradePlanPhaseNodeUpgrading) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(upgradePlan). + WithStatusSubresource(upgradePlan). + Build() + + require.NoError(t, fakeClient.Status().Update(context.Background(), upgradePlan)) + + phase := NewCollectPhase(&PhaseDeps{Client: fakeClient, Scheme: scheme}) + ul := newTestUpgradeLog() + ul.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseCollecting + + result, err := phase.Run(context.Background(), ul) + require.NoError(t, err) + assert.Zero(t, result.RequeueAfter) + assert.Equal(t, managementv1beta1.UpgradeLogPhaseCollecting, ul.Status.CurrentPhase) + }) + + t.Run("transitions to Collected when UpgradePlan is Succeeded", func(t *testing.T) { + scheme := newTestScheme(t) + upgradePlan := newTestUpgradePlan("my-plan", managementv1beta1.UpgradePlanPhaseSucceeded) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(upgradePlan). + WithStatusSubresource(upgradePlan). + Build() + + require.NoError(t, fakeClient.Status().Update(context.Background(), upgradePlan)) + + phase := NewCollectPhase(&PhaseDeps{Client: fakeClient, Scheme: scheme}) + ul := newTestUpgradeLog() + ul.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseCollecting + + result, err := phase.Run(context.Background(), ul) + require.NoError(t, err) + assert.Zero(t, result.RequeueAfter) + assert.Equal(t, managementv1beta1.UpgradeLogPhaseCollected, ul.Status.CurrentPhase) + }) + + t.Run("transitions to Collected when UpgradePlan is Failed", func(t *testing.T) { + scheme := newTestScheme(t) + upgradePlan := newTestUpgradePlan("my-plan", managementv1beta1.UpgradePlanPhaseFailed) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(upgradePlan). + WithStatusSubresource(upgradePlan). + Build() + + require.NoError(t, fakeClient.Status().Update(context.Background(), upgradePlan)) + + phase := NewCollectPhase(&PhaseDeps{Client: fakeClient, Scheme: scheme}) + ul := newTestUpgradeLog() + ul.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseCollecting + + result, err := phase.Run(context.Background(), ul) + require.NoError(t, err) + assert.Zero(t, result.RequeueAfter) + assert.Equal(t, managementv1beta1.UpgradeLogPhaseCollected, ul.Status.CurrentPhase) + }) + + t.Run("transitions to Collected when UpgradePlan does not exist", func(t *testing.T) { + scheme := newTestScheme(t) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + phase := NewCollectPhase(&PhaseDeps{Client: fakeClient, Scheme: scheme}) + ul := newTestUpgradeLog() + ul.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseCollecting + + result, err := phase.Run(context.Background(), ul) + require.NoError(t, err) + assert.Zero(t, result.RequeueAfter) + assert.Equal(t, managementv1beta1.UpgradeLogPhaseCollected, ul.Status.CurrentPhase) + }) +} diff --git a/pkg/upgradelog/pipeline.go b/pkg/upgradelog/pipeline.go index bf8810c..eacb982 100644 --- a/pkg/upgradelog/pipeline.go +++ b/pkg/upgradelog/pipeline.go @@ -38,9 +38,7 @@ func NewPipeline(deps *PhaseDeps) *Pipeline { { NewCollectPhase(deps), managementv1beta1.UpgradeLogPhaseCollecting, - // Collecting has no completed phase; it transitions externally - // when the UpgradePlan reaches a terminal state. - managementv1beta1.UpgradeLogPhase(""), + managementv1beta1.UpgradeLogPhaseCollected, }, { NewStopPhase(deps), @@ -145,14 +143,6 @@ func (p *Pipeline) enterPhase( return ctrl.Result{RequeueAfter: RequeueAfterDuration}, nil } -// TransitionToStop moves the UpgradeLog from Collecting to Stopping. -// Called externally when the UpgradePlan reaches a terminal state. -func (p *Pipeline) TransitionToStop(upgradeLog *managementv1beta1.UpgradeLog) { - if upgradeLog.Status.CurrentPhase == managementv1beta1.UpgradeLogPhaseCollecting { - upgradeLog.Status.CurrentPhase = managementv1beta1.UpgradeLogPhaseStopping - } -} - func (p *Pipeline) recordEvent( upgradeLog *managementv1beta1.UpgradeLog, eventType, reason, message string,