Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,13 @@ Usage: ./must_gather [params...]
│ │ │ ├── pods.txt
│ │ │ ├── pods.yaml
│ │ │ └── pods.describe.txt
│ │ ├── dependencies/ # Additional Helm Deployments, if present (for example, OKP)
│ │ │ └── [deployment-name]/
│ │ │ ├── deployment.yaml
│ │ │ ├── deployment.describe.txt
│ │ │ ├── logs/ # Per-pod, per-container current and previous logs
│ │ │ ├── pods/ # Pod listing and descriptions
│ │ │ └── rollout-history/
│ │ └── db-statefulset/ # Database StatefulSet info (if database enabled)
│ │ ├── db-statefulset.yaml
│ │ ├── db-statefulset.describe.txt
Expand Down
2 changes: 2 additions & 0 deletions docs/data-collected.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This tool focuses exclusively on RHDH-related resources, as well as some very mi
- **Release Information**: Helm releases, history, status
- **Configuration**: User-provided values, computed values, manifests, hooks, and notes
- **Kubernetes Manifests**: Deployments, StatefulSets with full YAML definitions and descriptions
- **Release Dependencies**: Additional Deployments in the release, such as the Intelligent Assistant OKP workload, including pod state, rollout history, and per-container logs
- **[Application Runtime Data](#application-runtime-data-extracted-from-rhdh-containers-if-running)**

#### Operator Deployments
Expand Down Expand Up @@ -96,6 +97,7 @@ RHDH can be deployed using the Orchestrator flavor, which includes additional in

#### Logs and Runtime Data
- **Per-pod, per-container logs**: Logs are collected from all pods (including non-running, for previous logs) and organized under `logs/pod=[pod-name]/container=[container-name]/` with `current.txt` and `previous.txt`. Containers are discovered dynamically (not hardcoded).
- **Intelligent Assistant diagnostics**: LCORE sidecar logs are collected with the RHDH Deployment, while a Helm-managed OKP Deployment is collected under the release's `dependencies/` directory.
- **Aggregated pod logs**: Each pod directory also contains `logs-app.current.txt` and `logs-app.previous.txt` combining all container logs with `--all-containers --prefix` for a unified view.
- **Configurable time windows**: Use `MUST_GATHER_SINCE` or `MUST_GATHER_SINCE_TIME` to limit log collection.
- **Local Database logs** from PostgreSQL StatefulSets, unless the app is configured to connect to external databases
Expand Down
80 changes: 70 additions & 10 deletions internal/collector/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,46 @@ func (h *Helm) collectReleaseData(ctx context.Context, cfg *Config, ns, name, re
notes := relAcc.Notes()
_ = os.WriteFile(filepath.Join(releaseDir, "notes.txt"), []byte(notes), 0o644)

// Extract workload names from manifest for collection
deployName, stsName := extractWorkloadNames(manifest)
if deployName != "" {
ref := WorkloadRef{Namespace: ns, Name: deployName, Kind: KindDeployment, InstanceName: name}
if err := CollectWorkload(ctx, cfg, ref, filepath.Join(releaseDir, "deployment")); err != nil {
log.Warn("Failed to collect workload %s/%s: %v", ns, deployName, err)
// Collect every Deployment rendered by the release. The Deployment
// containing backstage-backend is the primary RHDH workload; all others
// are release dependencies such as Intelligent Assistant's OKP.
deployNames, stsName := extractWorkloadNames(manifest)
deployments := make([]*appsv1.Deployment, 0, len(deployNames))
for _, deployName := range deployNames {
dep, err := cfg.Client.Clientset.AppsV1().Deployments(ns).Get(ctx, deployName, metav1.GetOptions{})
Comment thread
rm3l marked this conversation as resolved.
if err != nil {
log.Warn("Failed to get deployment %s/%s: %v", ns, deployName, err)
continue
}
processedWorkloads[ns+"/"+deployName] = true
deployments = append(deployments, dep)
}

primary := selectPrimaryDeployment(deployments)
if primary != nil && !deploymentHasContainer(primary, backstageContainer) {
log.Warn("No Deployment with a %s container found; using %s as the primary workload", backstageContainer, primary.Name)
}

for _, dep := range deployments {
outDir := filepath.Join(releaseDir, "dependencies", dep.Name)
skipAppData := true
if dep == primary {
outDir = filepath.Join(releaseDir, "deployment")
skipAppData = false
} else {
log.Info(" --> Collecting Helm dependency Deployment: %s", dep.Name)
}

ref := WorkloadRef{
Namespace: ns,
Name: dep.Name,
Kind: KindDeployment,
InstanceName: name,
SkipAppData: skipAppData,
}
if err := CollectWorkload(ctx, cfg, ref, outDir); err != nil {
log.Warn("Failed to collect workload %s/%s: %v", ns, dep.Name, err)
}
processedWorkloads[ns+"/"+dep.Name] = true
}
if stsName != "" {
if err := CollectDBStatefulSet(ctx, cfg, ns, stsName, releaseDir); err != nil {
Expand Down Expand Up @@ -542,7 +574,7 @@ func isSecretDocument(node *yaml.Node) bool {
return false
}

func extractWorkloadNames(manifest string) (deployName, stsName string) {
func extractWorkloadNames(manifest string) (deployNames []string, stsName string) {
decoder := yaml.NewDecoder(strings.NewReader(manifest))
for {
var doc struct {
Expand All @@ -556,8 +588,8 @@ func extractWorkloadNames(manifest string) (deployName, stsName string) {
}
switch doc.Kind {
case "Deployment":
if deployName == "" {
deployName = doc.Metadata.Name
if doc.Metadata.Name != "" {
deployNames = append(deployNames, doc.Metadata.Name)
}
case "StatefulSet":
if stsName == "" {
Expand All @@ -568,6 +600,34 @@ func extractWorkloadNames(manifest string) (deployName, stsName string) {
return
}

func deploymentHasContainer(dep *appsv1.Deployment, name string) bool {
for _, container := range dep.Spec.Template.Spec.Containers {
if container.Name == name {
return true
}
}
return false
}

func selectPrimaryDeployment(deployments []*appsv1.Deployment) *appsv1.Deployment {
if len(deployments) == 0 {
return nil
}

sorted := append([]*appsv1.Deployment(nil), deployments...)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Name < sorted[j].Name
})

for _, dep := range sorted {
if deploymentHasContainer(dep, backstageContainer) {
return dep
}
}

return sorted[0]
Comment thread
rm3l marked this conversation as resolved.
}

func chartNameFromAccessor(acc release.Accessor) string {
chartAcc, err := chart.NewAccessor(acc.Chart())
if err != nil {
Expand Down
96 changes: 90 additions & 6 deletions internal/collector/helm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"

appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

chartv2 "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/release"
Expand Down Expand Up @@ -97,13 +100,19 @@ metadata:
name: backstage-rhdh
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backstage-rhdh-ia-okp
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: backstage-psql-rhdh
`
deploy, sts := extractWorkloadNames(manifest)
if deploy != "backstage-rhdh" {
t.Errorf("deploy = %q, want backstage-rhdh", deploy)
deployments, sts := extractWorkloadNames(manifest)
wantDeployments := []string{"backstage-rhdh", "backstage-rhdh-ia-okp"}
if !reflect.DeepEqual(deployments, wantDeployments) {
t.Errorf("deployments = %q, want %q", deployments, wantDeployments)
}
if sts != "backstage-psql-rhdh" {
t.Errorf("sts = %q, want backstage-psql-rhdh", sts)
Expand All @@ -116,15 +125,90 @@ kind: Service
metadata:
name: my-service
`
deploy, sts := extractWorkloadNames(manifest)
if deploy != "" {
t.Errorf("deploy = %q, want empty", deploy)
deployments, sts := extractWorkloadNames(manifest)
if len(deployments) != 0 {
t.Errorf("deployments = %q, want empty", deployments)
}
if sts != "" {
t.Errorf("sts = %q, want empty", sts)
}
}

func TestDeploymentHasContainer(t *testing.T) {
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "rhdh"},
Spec: appsv1.DeploymentSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "backstage-backend"},
{Name: "lightspeed-core"},
},
},
},
},
}

if !deploymentHasContainer(dep, "backstage-backend") {
t.Error("expected backstage-backend container to identify the RHDH Deployment")
}
if deploymentHasContainer(dep, "okp") {
t.Error("did not expect an OKP container in the RHDH Deployment")
}
}

func TestSelectPrimaryDeployment(t *testing.T) {
deployment := func(name string, containers ...string) *appsv1.Deployment {
dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: name}}
for _, container := range containers {
dep.Spec.Template.Spec.Containers = append(dep.Spec.Template.Spec.Containers, corev1.Container{Name: container})
}
return dep
}

tests := []struct {
name string
deployments []*appsv1.Deployment
want string
}{
{
name: "multiple primary candidates use the first name",
deployments: []*appsv1.Deployment{
deployment("z-rhdh", "backstage-backend"),
deployment("okp", "okp"),
deployment("a-rhdh", "backstage-backend"),
},
want: "a-rhdh",
},
{
name: "first name is the fallback when no candidate matches",
deployments: []*appsv1.Deployment{
deployment("z-dependency", "worker"),
deployment("a-dependency", "okp"),
},
want: "a-dependency",
},
{
name: "empty list has no primary",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectPrimaryDeployment(tt.deployments)
if got == nil {
if tt.want != "" {
t.Fatalf("selectPrimaryDeployment() = nil, want %q", tt.want)
}
return
}
if got.Name != tt.want {
t.Errorf("selectPrimaryDeployment() = %q, want %q", got.Name, tt.want)
}
})
}
}

func TestIsSecretDocument(t *testing.T) {
tests := []struct {
yaml string
Expand Down
7 changes: 5 additions & 2 deletions internal/collector/workload.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type WorkloadRef struct {
Name string
Kind WorkloadKind
InstanceName string
SkipAppData bool
}

func CollectWorkload(ctx context.Context, cfg *Config, ref WorkloadRef, outDir string) error {
Expand Down Expand Up @@ -103,7 +104,7 @@ func CollectWorkload(ctx context.Context, cfg *Config, ref WorkloadRef, outDir s
CollectPodLogs(ctx, cfg, ns, pod, filepath.Join(outDir, "logs", "pod="+pod.Name))
}()

if pod.Status.Phase == corev1.PodRunning {
if pod.Status.Phase == corev1.PodRunning && !ref.SkipAppData {
wg.Add(1)
go func() {
defer wg.Done()
Expand All @@ -118,7 +119,9 @@ func CollectWorkload(ctx context.Context, cfg *Config, ref WorkloadRef, outDir s
}
wg.Wait()

collectHeapDumps(cfg, ns, labelSelector, outDir, ref.Name, ref.InstanceName, string(ref.Kind))
if !ref.SkipAppData {
collectHeapDumps(cfg, ns, labelSelector, outDir, ref.Name, ref.InstanceName, string(ref.Kind))
}

return nil
}
Expand Down