From bc8ed87397d580c9033e7ed49907c9bd9773d068 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Sun, 25 Jan 2026 19:17:33 +0800 Subject: [PATCH] feat: implement getReleaseResources for kubedog tracking Implement the getReleaseResources method to detect Kubernetes resources from Helm chart manifests for kubedog tracking. This implementation: 1. Creates a new pkg/cluster package with: - Resource struct for resource metadata (kind, name, namespace) - ReleaseResources struct for holding all resources in a release - TrackConfig for custom tracking configuration - GetReleaseResourcesFromManifest() to parse helm template output - Helper functions for filtering trackable vs static resources - Helper functions for getting Helm release labels/annotations 2. Implements getReleaseResources() method that: - Runs helm template to generate Kubernetes manifests - Reads all YAML files from template output directory - Combines files into a single manifest with document separators - Parses manifest using k8s.io/apimachinery unstructured decoder - Converts cluster.Resource to kubedog.ResourceSpec for tracking - Cleans up temporary files after parsing 3. Removes unused functions from helmx.go: - parseResourceKindAndName() - replaced by manifest parsing - isTrackableResourceKind() - moved to cluster package Benefits: - Works offline without Kubernetes cluster connection - Faster than API-based resource detection - More reliable and deterministic - Enables kubedog tracking with proper resource filtering Fixes: #2383 Signed-off-by: yxxhero --- pkg/cluster/release.go | 275 +++++++++++++++++++++++++++++++++++++++++ pkg/state/helmx.go | 107 ++++++++++++---- 2 files changed, 358 insertions(+), 24 deletions(-) create mode 100644 pkg/cluster/release.go diff --git a/pkg/cluster/release.go b/pkg/cluster/release.go new file mode 100644 index 00000000..2abafcc9 --- /dev/null +++ b/pkg/cluster/release.go @@ -0,0 +1,275 @@ +package cluster + +import ( + "bytes" + "fmt" + + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/yaml" +) + +type Resource struct { + Kind string + Name string + Namespace string + Manifest string +} + +type ReleaseResources struct { + ReleaseName string + Namespace string + Resources []Resource +} + +type TrackConfig struct { + TrackKinds []string + SkipKinds []string + CustomTrackableKinds []string + CustomStaticKinds []string +} + +func GetReleaseResourcesFromManifest(manifest []byte, releaseName, releaseNamespace string) (*ReleaseResources, error) { + return GetReleaseResourcesFromManifestWithLogger(nil, manifest, releaseName, releaseNamespace, nil) +} + +func GetReleaseResourcesFromManifestWithConfig(manifest []byte, releaseName, releaseNamespace string, config *TrackConfig) (*ReleaseResources, error) { + return GetReleaseResourcesFromManifestWithLogger(nil, manifest, releaseName, releaseNamespace, config) +} + +func GetReleaseResourcesFromManifestWithLogger(logger *zap.SugaredLogger, manifest []byte, releaseName, releaseNamespace string, config *TrackConfig) (*ReleaseResources, error) { + resources, err := parseManifest(manifest, logger) + if err != nil { + return nil, fmt.Errorf("failed to parse manifest: %w", err) + } + + if len(resources) == 0 { + if logger != nil { + logger.Debugf("No resources found in manifest for release %s", releaseName) + } + return &ReleaseResources{ + ReleaseName: releaseName, + Namespace: releaseNamespace, + Resources: resources, + }, nil + } + + if config != nil { + filteredResources := filterResourcesByConfig(resources, config, logger) + if logger != nil { + logger.Infof("Found %d resources in manifest for release %s (filtered from %d total)", len(filteredResources), releaseName, len(resources)) + for _, res := range filteredResources { + logger.Debugf(" - %s/%s in namespace %s", res.Kind, res.Name, res.Namespace) + } + } + return &ReleaseResources{ + ReleaseName: releaseName, + Namespace: releaseNamespace, + Resources: filteredResources, + }, nil + } + + if logger != nil { + logger.Infof("Found %d resources in manifest for release %s", len(resources), releaseName) + for _, res := range resources { + logger.Debugf(" - %s/%s in namespace %s", res.Kind, res.Name, res.Namespace) + } + } + + return &ReleaseResources{ + ReleaseName: releaseName, + Namespace: releaseNamespace, + Resources: resources, + }, nil +} + +func filterResourcesByConfig(resources []Resource, config *TrackConfig, logger *zap.SugaredLogger) []Resource { + var filtered []Resource + + for _, res := range resources { + if shouldSkipResource(res.Kind, config, logger) { + if logger != nil { + logger.Debugf("Skipping resource %s/%s (kind: %s) based on configuration", res.Kind, res.Name, res.Kind) + } + continue + } + filtered = append(filtered, res) + } + + return filtered +} + +func shouldSkipResource(kind string, config *TrackConfig, logger *zap.SugaredLogger) bool { + if len(config.TrackKinds) > 0 { + shouldTrack := false + for _, trackKind := range config.TrackKinds { + if kind == trackKind { + shouldTrack = true + break + } + } + if !shouldTrack { + if logger != nil { + logger.Debugf("Resource kind %s is not in TrackKinds list, skipping", kind) + } + return true + } + } + + if len(config.SkipKinds) > 0 { + for _, skipKind := range config.SkipKinds { + if kind == skipKind { + if logger != nil { + logger.Debugf("Resource kind %s is in SkipKinds list, skipping", kind) + } + return true + } + } + } + + return false +} + +func parseManifest(manifest []byte, logger *zap.SugaredLogger) ([]Resource, error) { + var resources []Resource + + decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewReader(manifest), 4096) + + for { + var obj unstructured.Unstructured + err := decoder.Decode(&obj) + if err != nil { + if err.Error() == "EOF" { + break + } + return nil, fmt.Errorf("failed to decode manifest: %w", err) + } + + if len(obj.Object) == 0 { + continue + } + + kind := obj.GetKind() + if kind == "" { + if logger != nil { + logger.Debugf("Skipping resource without kind") + } + continue + } + + name := obj.GetName() + if name == "" { + if logger != nil { + logger.Debugf("Skipping %s resource without name", kind) + } + continue + } + + namespace := obj.GetNamespace() + if namespace == "" { + namespace = "default" + } + + manifestBytes, err := yaml.Marshal(obj.Object) + if err != nil { + if logger != nil { + logger.Debugf("Failed to marshal %s/%s: %v", kind, name, err) + } + continue + } + + res := Resource{ + Kind: kind, + Name: name, + Namespace: namespace, + Manifest: string(manifestBytes), + } + resources = append(resources, res) + } + + return resources, nil +} + +func IsTrackableKind(kind string) bool { + trackableKinds := map[string]bool{ + "Deployment": true, + "StatefulSet": true, + "DaemonSet": true, + "Job": true, + "Pod": true, + "ReplicaSet": true, + } + return trackableKinds[kind] +} + +func IsTrackableKindWithConfig(kind string, config *TrackConfig) bool { + if config == nil { + return IsTrackableKind(kind) + } + + if len(config.CustomTrackableKinds) > 0 { + for _, customKind := range config.CustomTrackableKinds { + if kind == customKind { + return true + } + } + return false + } + + return IsTrackableKind(kind) +} + +func IsStaticKind(kind string) bool { + staticKinds := map[string]bool{ + "Service": true, + "ConfigMap": true, + "Secret": true, + "PersistentVolumeClaim": true, + "PersistentVolume": true, + "StorageClass": true, + "Namespace": true, + "ResourceQuota": true, + "LimitRange": true, + "PriorityClass": true, + "ServiceAccount": true, + "Role": true, + "RoleBinding": true, + "ClusterRole": true, + "ClusterRoleBinding": true, + "NetworkPolicy": true, + "Ingress": true, + "CustomResourceDefinition": true, + } + return staticKinds[kind] +} + +func IsStaticKindWithConfig(kind string, config *TrackConfig) bool { + if config == nil { + return IsStaticKind(kind) + } + + if len(config.CustomStaticKinds) > 0 { + for _, customKind := range config.CustomStaticKinds { + if kind == customKind { + return true + } + } + return false + } + + return IsStaticKind(kind) +} + +func GetHelmReleaseLabels(releaseName, releaseNamespace string) map[string]string { + return map[string]string{ + "meta.helm.sh/release-name": releaseName, + "meta.helm.sh/release-namespace": releaseNamespace, + } +} + +func GetHelmReleaseAnnotations(releaseName string) map[string]string { + return map[string]string{ + "meta.helm.sh/release-name": releaseName, + } +} diff --git a/pkg/state/helmx.go b/pkg/state/helmx.go index b9af9731..598d315e 100644 --- a/pkg/state/helmx.go +++ b/pkg/state/helmx.go @@ -13,6 +13,7 @@ import ( "github.com/helmfile/chartify" "helm.sh/helm/v4/pkg/storage/driver" + "github.com/helmfile/helmfile/pkg/cluster" "github.com/helmfile/helmfile/pkg/helmexec" "github.com/helmfile/helmfile/pkg/kubedog" "github.com/helmfile/helmfile/pkg/remote" @@ -503,32 +504,90 @@ func (st *HelmState) trackWithKubeDog(ctx context.Context, release *ReleaseSpec, func (st *HelmState) getReleaseResources(ctx context.Context, release *ReleaseSpec, helm helmexec.Interface) ([]*kubedog.ResourceSpec, error) { st.logger.Debugf("Getting resources for release %s", release.Name) - // TODO: Implement resource detection from manifest - // For now, return empty list to avoid compilation errors - return nil, nil -} - -func (st *HelmState) parseResourceKindAndName(line string) (string, string, error) { - parts := strings.Fields(line) - if len(parts) < 3 { - return "", "", nil + manifest, err := st.getReleaseManifest(ctx, release, helm) + if err != nil { + return nil, fmt.Errorf("failed to get release manifest: %w", err) } - kind := strings.TrimSpace(parts[0]) - name := strings.TrimSpace(parts[1]) - - return kind, name, nil -} - -func (st *HelmState) isTrackableResourceKind(kind string) bool { - trackableKinds := map[string]bool{ - "Deployment": true, - "StatefulSet": true, - "DaemonSet": true, - "Job": true, - "Pod": true, - "ReplicaSet": true, + if len(manifest) == 0 { + st.logger.Infof("No manifest found for release %s", release.Name) + return nil, nil } - return trackableKinds[kind] + releaseResources, err := cluster.GetReleaseResourcesFromManifest(manifest, release.Name, release.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to parse release resources from manifest: %w", err) + } + + var resources []*kubedog.ResourceSpec + for _, res := range releaseResources.Resources { + resources = append(resources, &kubedog.ResourceSpec{ + Name: res.Name, + Namespace: res.Namespace, + Kind: res.Kind, + }) + } + + return resources, nil +} + +func (st *HelmState) getReleaseManifest(ctx context.Context, release *ReleaseSpec, helm helmexec.Interface) ([]byte, error) { + tempDir, err := st.tempDir("", "helmfile-template-") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + st.logger.Warnf("Failed to remove temp directory %s: %v", tempDir, err) + } + }() + + st.ApplyOverrides(release) + + flags, files, err := st.flagsForTemplate(helm, release, 0, &TemplateOpts{}) + defer st.removeFiles(files) + + if err != nil { + return nil, fmt.Errorf("failed to generate template flags: %w", err) + } + + flags = append(flags, "--output-dir", tempDir) + + if err := helm.TemplateRelease(release.Name, release.ChartPathOrName(), flags...); err != nil { + return nil, fmt.Errorf("failed to run helm template: %w", err) + } + + var manifest []byte + + err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + if !strings.HasSuffix(info.Name(), ".yaml") && !strings.HasSuffix(info.Name(), ".yml") { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + if len(manifest) > 0 { + manifest = append(manifest, []byte("\n---\n")...) + } + manifest = append(manifest, content...) + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk template output directory: %w", err) + } + + return manifest, nil }