diff --git a/cmd/main.go b/cmd/main.go index 522af0e3..61128cfe 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -399,6 +399,7 @@ func run(cfg *Config) error { statsInfo, selectionInfo, cfg.ArgocdUIURL, + cfg.SummaryThreshold, cfg.IgnoreResourceRules, ) if err != nil { diff --git a/cmd/options.go b/cmd/options.go index 86d20a2b..6cabed7f 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -57,6 +57,7 @@ var ( DefaultKindInternal = false DefaultK3dOptions = "" DefaultMaxDiffLength = uint(65536) + DefaultSummaryThreshold = uint(20) DefaultArgocdNamespace = "argocd" DefaultArgocdChartVersion = "latest" DefaultArgocdChartName = "argo" @@ -105,6 +106,7 @@ type RawOptions struct { KindInternal bool `mapstructure:"kind-internal"` K3dOptions string `mapstructure:"k3d-options"` MaxDiffLength uint `mapstructure:"max-diff-length"` + SummaryThreshold uint `mapstructure:"summary-threshold"` Selector string `mapstructure:"selector"` FilesChanged string `mapstructure:"files-changed"` IgnoreInvalidWatchPattern bool `mapstructure:"ignore-invalid-watch-pattern"` @@ -153,6 +155,7 @@ type Config struct { KindInternal bool K3dOptions string MaxDiffLength uint + SummaryThreshold uint IgnoreInvalidWatchPattern bool WatchIfNoWatchPatternFound bool AutoDetectFilesChanged bool @@ -252,6 +255,7 @@ func Parse() *Config { viper.SetDefault("cluster", DefaultCluster) viper.SetDefault("cluster-name", DefaultClusterName) viper.SetDefault("max-diff-length", DefaultMaxDiffLength) + viper.SetDefault("summary-threshold", DefaultSummaryThreshold) viper.SetDefault("argocd-namespace", DefaultArgocdNamespace) viper.SetDefault("argocd-chart-version", DefaultArgocdChartVersion) viper.SetDefault("argocd-chart-name", DefaultArgocdChartName) @@ -318,6 +322,7 @@ func Parse() *Config { // Other options rootCmd.Flags().String("max-diff-length", fmt.Sprintf("%d", DefaultMaxDiffLength), "Max diff message character count") + rootCmd.Flags().Uint("summary-threshold", DefaultSummaryThreshold, "Collapse summary details when total changed applications exceed this value (0 = always show inline)") rootCmd.Flags().StringP("selector", "l", "", "Label selector to filter on (e.g. key1=value1,key2=value2)") rootCmd.Flags().String("files-changed", "", "List of files changed between branches (comma, space or newline separated)") rootCmd.Flags().Bool("auto-detect-files-changed", DefaultAutoDetectFilesChanged, "Auto detect files changed between branches") @@ -394,6 +399,7 @@ func (o *RawOptions) ToConfig() (*Config, error) { KindInternal: o.KindInternal, K3dOptions: o.K3dOptions, MaxDiffLength: o.MaxDiffLength, + SummaryThreshold: o.SummaryThreshold, IgnoreInvalidWatchPattern: o.IgnoreInvalidWatchPattern, WatchIfNoWatchPatternFound: o.WatchIfNoWatchPatternFound, AutoDetectFilesChanged: o.AutoDetectFilesChanged, @@ -671,6 +677,9 @@ func (o *Config) LogConfig() { if o.MaxDiffLength != DefaultMaxDiffLength { log.Info().Msgf("✨ - max-diff-length: %d", o.MaxDiffLength) } + if o.SummaryThreshold != DefaultSummaryThreshold { + log.Info().Msgf("✨ - summary-threshold: %d", o.SummaryThreshold) + } if len(o.FilesChanged) > 0 { log.Info().Msgf("✨ - files-changed: %v", o.FilesChanged) } else if o.AutoDetectFilesChanged { diff --git a/docs/options.md b/docs/options.md index adfb8a7a..f43a08a3 100644 --- a/docs/options.md +++ b/docs/options.md @@ -61,6 +61,7 @@ argocd-diff-preview [FLAGS] [OPTIONS] --repo --target-branch `, `-c` | `LINE_COUNT` | `5` | Generate diffs with \ lines of context | | `--log-format ` | `LOG_FORMAT` | `human` | Log format. Options: `human`, `json` | | `--max-diff-length ` | `MAX_DIFF_LENGTH` | `65536` | Max diff message character count (only limits the generated Markdown file) | +| `--summary-threshold ` | `SUMMARY_THRESHOLD` | `20` | Collapse summary details when total changed applications exceed this value (`0` = always show inline) | | `--output-folder `, `-o` | `OUTPUT_FOLDER` | `./output` | Output folder where the diff will be saved | | `--redirect-target-revisions ` | `REDIRECT_TARGET_REVISIONS` | - | List of target revisions to redirect | | `--render-method ` | `RENDER_METHOD` | `server-api` | Manifest rendering method. Options: `cli`, `server-api`, `repo-server-api` | diff --git a/integration-test/branch-9/target-4/output.html b/integration-test/branch-9/target-4/output.html new file mode 100644 index 00000000..6d07e0fc --- /dev/null +++ b/integration-test/branch-9/target-4/output.html @@ -0,0 +1,1121 @@ + + + + + + +
+

Argo CD Diff Preview

+ +

Summary:

+
Total: 9 applications changed
+Deleted: 9
+ +
+
+Detailed Summary (9) +
Deleted (9):
+- app1 (-19)
+- app1 (-19)
+- app2 (-19)
+- app2 (-19)
+- custom-target-revision-example (-14)
+- my-app-set-dev (-79)
+- my-app-set-prod (-79)
+- my-app-set-staging (-79)
+- nginx-ingress (-470)
+
+
+
+
+ +app1 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app1 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +custom-target-revision-example (examples/custom-target-revision/app/app.yaml) + + +

Deployment: default/my-deployment

+
+ + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: my-deployment
-  namespace: default
-spec:
-  replicas: 2
-  template:
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: my-deployment
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +my-app-set-dev (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-dev
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-dev
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-prod (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-prod
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-prod
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-staging (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-staging
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-staging
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +nginx-ingress (examples/external-chart/nginx.yaml) + + +

ValidatingWebhookConfiguration: nginx-ingress-ingress-nginx-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: admissionregistration.k8s.io/v1
-kind: ValidatingWebhookConfiguration
-metadata:
-  labels:
-    app.kubernetes.io/component: admission-webhook
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-admission
-webhooks:
-- admissionReviewVersions:
-  - v1
-  clientConfig:
-    service:
-      name: nginx-ingress-ingress-nginx-controller-admission
-      namespace: default
-      path: /networking/v1/ingresses
-  failurePolicy: Fail
-  matchPolicy: Equivalent
-  name: validate.nginx.ingress.kubernetes.io
-  rules:
-  - apiGroups:
-    - networking.k8s.io
-    apiVersions:
-    - v1
-    operations:
-    - CREATE
-    - UPDATE
-    resources:
-    - ingresses
-  sideEffects: None
+
+ +

Deployment: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  minReadySeconds: 0
-  replicas: 1
-  revisionHistoryLimit: 10
-  selector:
-    matchLabels:
-      app.kubernetes.io/component: controller
-      app.kubernetes.io/instance: nginx-ingress
-      app.kubernetes.io/name: ingress-nginx
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/component: controller
-        app.kubernetes.io/instance: nginx-ingress
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: ingress-nginx
-        app.kubernetes.io/part-of: ingress-nginx
-        app.kubernetes.io/version: 1.10.0
-        helm.sh/chart: ingress-nginx-4.10.0
-    spec:
-      containers:
-      - args:
-        - /nginx-ingress-controller
-        - --publish-service=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --election-id=nginx-ingress-ingress-nginx-leader
-        - --controller-class=k8s.io/ingress-nginx
-        - --ingress-class=test
-        - --configmap=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --validating-webhook=:8443
-        - --validating-webhook-certificate=/usr/local/certificates/cert
-        - --validating-webhook-key=/usr/local/certificates/key
-        - --enable-metrics=false
-        env:
-        - name: POD_NAME
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.name
-        - name: POD_NAMESPACE
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.namespace
-        - name: LD_PRELOAD
-          value: /usr/local/lib/libmimalloc.so
-        image: registry.k8s.io/ingress-nginx/controller:v1.10.0@sha256:42b3f0e5d0846876b1791cd3afeb5f1cbbe4259d6f35651dcc1b5c980925379c
-        imagePullPolicy: IfNotPresent
-        lifecycle:
-          preStop:
-            exec:
-              command:
-              - /wait-shutdown
-        livenessProbe:
-          failureThreshold: 5
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        name: controller
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        - containerPort: 443
-          name: https
-          protocol: TCP
-        - containerPort: 8443
-          name: webhook
-          protocol: TCP
-        readinessProbe:
-          failureThreshold: 3
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        resources:
-          requests:
-            cpu: 100m
-            memory: 90Mi
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            add:
-            - NET_BIND_SERVICE
-            drop:
-            - ALL
-          readOnlyRootFilesystem: false
-          runAsNonRoot: true
-          runAsUser: 101
-          seccompProfile:
-            type: RuntimeDefault
-        volumeMounts:
-        - mountPath: /usr/local/certificates/
-          name: webhook-cert
-          readOnly: true
-      dnsPolicy: ClusterFirst
-      nodeSelector:
-        kubernetes.io/os: linux
-      serviceAccountName: nginx-ingress-ingress-nginx
-      terminationGracePeriodSeconds: 300
-      volumes:
-      - name: webhook-cert
-        secret:
-          secretName: nginx-ingress-ingress-nginx-admission
+
+ +

IngressClass: nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: networking.k8s.io/v1
-kind: IngressClass
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx
-spec:
-  controller: k8s.io/ingress-nginx
+
+ +

ClusterRole: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - endpoints
-  - nodes
-  - pods
-  - secrets
-  - namespaces
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - nodes
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

ClusterRoleBinding: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

Role: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - namespaces
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - pods
-  - secrets
-  - endpoints
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resourceNames:
-  - nginx-ingress-ingress-nginx-leader
-  resources:
-  - leases
-  verbs:
-  - get
-  - update
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - create
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

RoleBinding: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: Role
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

ConfigMap: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + +
-apiVersion: v1
-data:
-  allow-snippet-annotations: "false"
-kind: ConfigMap
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  ipFamilies:
-  - IPv4
-  ipFamilyPolicy: SingleStack
-  ports:
-  - appProtocol: http
-    name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  - appProtocol: https
-    name: https
-    port: 443
-    protocol: TCP
-    targetPort: https
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: LoadBalancer
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller-admission
-  namespace: default
-spec:
-  ports:
-  - appProtocol: https
-    name: https-webhook
-    port: 443
-    targetPort: webhook
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: ClusterIP
+
+ +

ServiceAccount: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +
+
+ +
_Stats_:
+[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs]
+
+ + diff --git a/integration-test/branch-9/target-4/output.md b/integration-test/branch-9/target-4/output.md new file mode 100644 index 00000000..a2792701 --- /dev/null +++ b/integration-test/branch-9/target-4/output.md @@ -0,0 +1,448 @@ +## Argo CD Diff Preview + +Summary: +```yaml +Total: 9 applications changed +Deleted: 9 +``` + +
+Detailed Summary (9) + +```yaml +Deleted (9): +- app1 (-19) +- app1 (-19) +- app2 (-19) +- app2 (-19) +- custom-target-revision-example (-14) +- my-app-set-dev (-79) +- my-app-set-prod (-79) +- my-app-set-staging (-79) +- nginx-ingress (-470) +``` + +
+ +
+app1 (examples/duplicate-names/app/app-set-1.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+app1 (examples/duplicate-names/app/app-set-2.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+app2 (examples/duplicate-names/app/app-set-1.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+app2 (examples/duplicate-names/app/app-set-2.yaml) +
+ +#### Deployment: deploy-from-folder-one +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: deploy-from-folder-one +-spec: +- replicas: 2 +- selector: +- matchLabels: +- app: myapp +- template: +- metadata: +- labels: +- app: myapp +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: myapp +- ports: +- - containerPort: 80 +``` +
+ +
+custom-target-revision-example (examples/custom-target-revision/app/app.yaml) +
+ +#### Deployment: default/my-deployment +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- name: my-deployment +- namespace: default +-spec: +- replicas: 2 +- template: +- spec: +- containers: +- - image: dag-andersen/myapp:latest +- name: my-deployment +- ports: +- - containerPort: 80 +``` +
+ +
+my-app-set-dev (examples/basic-appset/my-app-set.yaml) +
+ +#### Deployment: default/super-app-name +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- replicas: 1 +- selector: +- matchLabels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/name: myApp +- template: +- metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- spec: +- containers: +- - image: nginx:1.16.0 +- imagePullPolicy: IfNotPresent +- livenessProbe: +- httpGet: +- path: / +- port: http +- name: myApp +- ports: +- - containerPort: 80 +- name: http +- protocol: TCP +- readinessProbe: +- httpGet: +- path: / +- port: http +- resources: {} +- securityContext: {} +- securityContext: {} +- serviceAccountName: super-app-name +``` +#### Service: default/super-app-name +```diff +-apiVersion: v1 +-kind: Service +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- ports: +- - name: http +- port: 80 +- protocol: TCP +- targetPort: http +- selector: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/name: myApp +- type: ClusterIP +``` +#### ServiceAccount: default/super-app-name +```diff +-apiVersion: v1 +-automountServiceAccountToken: true +-kind: ServiceAccount +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-dev +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +``` +
+ +
+my-app-set-prod (examples/basic-appset/my-app-set.yaml) +
+ +#### Deployment: default/super-app-name +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- replicas: 1 +- selector: +- matchLabels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/name: myApp +- template: +- metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- spec: +- containers: +- - image: nginx:1.16.0 +- imagePullPolicy: IfNotPresent +- livenessProbe: +- httpGet: +- path: / +- port: http +- name: myApp +- ports: +- - containerPort: 80 +- name: http +- protocol: TCP +- readinessProbe: +- httpGet: +- path: / +- port: http +- resources: {} +- securityContext: {} +- securityContext: {} +- serviceAccountName: super-app-name +``` +#### Service: default/super-app-name +```diff +-apiVersion: v1 +-kind: Service +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- ports: +- - name: http +- port: 80 +- protocol: TCP +- targetPort: http +- selector: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/name: myApp +- type: ClusterIP +``` +#### ServiceAccount: default/super-app-name +```diff +-apiVersion: v1 +-automountServiceAccountToken: true +-kind: ServiceAccount +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-prod +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +``` +
+ +
+my-app-set-staging (examples/basic-appset/my-app-set.yaml) +
+ +#### Deployment: default/super-app-name +```diff +-apiVersion: apps/v1 +-kind: Deployment +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- replicas: 1 +- selector: +- matchLabels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/name: myApp +- template: +- metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- spec: +- containers: +- - image: nginx:1.16.0 +- imagePullPolicy: IfNotPresent +- livenessProbe: +- httpGet: +- path: / +- port: http +- name: myApp +- ports: +- - containerPort: 80 +- name: http +- protocol: TCP +- readinessProbe: +- httpGet: +- path: / +- port: http +- resources: {} +- securityContext: {} +- securityContext: {} +- serviceAccountName: super-app-name +``` +#### Service: default/super-app-name +```diff +-apiVersion: v1 +-kind: Service +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: super-app-name +- namespace: default +-spec: +- ports: +- - name: http +- port: 80 +- protocol: TCP +- targetPort: http +- selector: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/name: myApp +- type: ClusterIP +``` +#### ServiceAccount: default/super-app-name +```diff +-apiVersion: v1 +-automountServiceAccountToken: true +-kind: ServiceAccount +-metadata: +- labels: +- app.kubernetes.io/instance: my-app-set-staging +- app.kubernetes.io/managed-by: Helm +- app.kubernetes.io/name: myApp +- app.kubernetes.io/version: 1.16.0 +- helm.sh/chart: myApp-0.1.0 +- name: sup +``` + +🚨 Diff is too long +
+ +⚠️⚠️⚠️ Diff exceeds max length of 10000 characters. Truncating to fit. This can be adjusted with the `--max-diff-length` flag + +_Stats_: +[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs] diff --git a/integration-test/branch-9/target-5/output.html b/integration-test/branch-9/target-5/output.html new file mode 100644 index 00000000..6d07e0fc --- /dev/null +++ b/integration-test/branch-9/target-5/output.html @@ -0,0 +1,1121 @@ + + + + + + +
+

Argo CD Diff Preview

+ +

Summary:

+
Total: 9 applications changed
+Deleted: 9
+ +
+
+Detailed Summary (9) +
Deleted (9):
+- app1 (-19)
+- app1 (-19)
+- app2 (-19)
+- app2 (-19)
+- custom-target-revision-example (-14)
+- my-app-set-dev (-79)
+- my-app-set-prod (-79)
+- my-app-set-staging (-79)
+- nginx-ingress (-470)
+
+
+
+
+ +app1 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app1 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-1.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +app2 (examples/duplicate-names/app/app-set-2.yaml) + + +

Deployment: deploy-from-folder-one

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: deploy-from-folder-one
-spec:
-  replicas: 2
-  selector:
-    matchLabels:
-      app: myapp
-  template:
-    metadata:
-      labels:
-        app: myapp
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: myapp
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +custom-target-revision-example (examples/custom-target-revision/app/app.yaml) + + +

Deployment: default/my-deployment

+
+ + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: my-deployment
-  namespace: default
-spec:
-  replicas: 2
-  template:
-    spec:
-      containers:
-      - image: dag-andersen/myapp:latest
-        name: my-deployment
-        ports:
-        - containerPort: 80
+
+ +
+ +
+ +my-app-set-dev (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-dev
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-dev
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-dev
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-prod (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-prod
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-prod
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-prod
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +my-app-set-staging (examples/basic-appset/my-app-set.yaml) + + +

Deployment: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app.kubernetes.io/instance: my-app-set-staging
-      app.kubernetes.io/name: myApp
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/instance: my-app-set-staging
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: myApp
-        app.kubernetes.io/version: 1.16.0
-        helm.sh/chart: myApp-0.1.0
-    spec:
-      containers:
-      - image: nginx:1.16.0
-        imagePullPolicy: IfNotPresent
-        livenessProbe:
-          httpGet:
-            path: /
-            port: http
-        name: myApp
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        readinessProbe:
-          httpGet:
-            path: /
-            port: http
-        resources: {}
-        securityContext: {}
-      securityContext: {}
-      serviceAccountName: super-app-name
+
+ +

Service: default/super-app-name

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
-spec:
-  ports:
-  - name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  selector:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/name: myApp
-  type: ClusterIP
+
+ +

ServiceAccount: default/super-app-name

+
+ + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/instance: my-app-set-staging
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: myApp
-    app.kubernetes.io/version: 1.16.0
-    helm.sh/chart: myApp-0.1.0
-  name: super-app-name
-  namespace: default
+
+ +
+ +
+ +nginx-ingress (examples/external-chart/nginx.yaml) + + +

ValidatingWebhookConfiguration: nginx-ingress-ingress-nginx-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: admissionregistration.k8s.io/v1
-kind: ValidatingWebhookConfiguration
-metadata:
-  labels:
-    app.kubernetes.io/component: admission-webhook
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-admission
-webhooks:
-- admissionReviewVersions:
-  - v1
-  clientConfig:
-    service:
-      name: nginx-ingress-ingress-nginx-controller-admission
-      namespace: default
-      path: /networking/v1/ingresses
-  failurePolicy: Fail
-  matchPolicy: Equivalent
-  name: validate.nginx.ingress.kubernetes.io
-  rules:
-  - apiGroups:
-    - networking.k8s.io
-    apiVersions:
-    - v1
-    operations:
-    - CREATE
-    - UPDATE
-    resources:
-    - ingresses
-  sideEffects: None
+
+ +

Deployment: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  minReadySeconds: 0
-  replicas: 1
-  revisionHistoryLimit: 10
-  selector:
-    matchLabels:
-      app.kubernetes.io/component: controller
-      app.kubernetes.io/instance: nginx-ingress
-      app.kubernetes.io/name: ingress-nginx
-  template:
-    metadata:
-      labels:
-        app.kubernetes.io/component: controller
-        app.kubernetes.io/instance: nginx-ingress
-        app.kubernetes.io/managed-by: Helm
-        app.kubernetes.io/name: ingress-nginx
-        app.kubernetes.io/part-of: ingress-nginx
-        app.kubernetes.io/version: 1.10.0
-        helm.sh/chart: ingress-nginx-4.10.0
-    spec:
-      containers:
-      - args:
-        - /nginx-ingress-controller
-        - --publish-service=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --election-id=nginx-ingress-ingress-nginx-leader
-        - --controller-class=k8s.io/ingress-nginx
-        - --ingress-class=test
-        - --configmap=$(POD_NAMESPACE)/nginx-ingress-ingress-nginx-controller
-        - --validating-webhook=:8443
-        - --validating-webhook-certificate=/usr/local/certificates/cert
-        - --validating-webhook-key=/usr/local/certificates/key
-        - --enable-metrics=false
-        env:
-        - name: POD_NAME
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.name
-        - name: POD_NAMESPACE
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.namespace
-        - name: LD_PRELOAD
-          value: /usr/local/lib/libmimalloc.so
-        image: registry.k8s.io/ingress-nginx/controller:v1.10.0@sha256:42b3f0e5d0846876b1791cd3afeb5f1cbbe4259d6f35651dcc1b5c980925379c
-        imagePullPolicy: IfNotPresent
-        lifecycle:
-          preStop:
-            exec:
-              command:
-              - /wait-shutdown
-        livenessProbe:
-          failureThreshold: 5
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        name: controller
-        ports:
-        - containerPort: 80
-          name: http
-          protocol: TCP
-        - containerPort: 443
-          name: https
-          protocol: TCP
-        - containerPort: 8443
-          name: webhook
-          protocol: TCP
-        readinessProbe:
-          failureThreshold: 3
-          httpGet:
-            path: /healthz
-            port: 10254
-            scheme: HTTP
-          initialDelaySeconds: 10
-          periodSeconds: 10
-          successThreshold: 1
-          timeoutSeconds: 1
-        resources:
-          requests:
-            cpu: 100m
-            memory: 90Mi
-        securityContext:
-          allowPrivilegeEscalation: false
-          capabilities:
-            add:
-            - NET_BIND_SERVICE
-            drop:
-            - ALL
-          readOnlyRootFilesystem: false
-          runAsNonRoot: true
-          runAsUser: 101
-          seccompProfile:
-            type: RuntimeDefault
-        volumeMounts:
-        - mountPath: /usr/local/certificates/
-          name: webhook-cert
-          readOnly: true
-      dnsPolicy: ClusterFirst
-      nodeSelector:
-        kubernetes.io/os: linux
-      serviceAccountName: nginx-ingress-ingress-nginx
-      terminationGracePeriodSeconds: 300
-      volumes:
-      - name: webhook-cert
-        secret:
-          secretName: nginx-ingress-ingress-nginx-admission
+
+ +

IngressClass: nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: networking.k8s.io/v1
-kind: IngressClass
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx
-spec:
-  controller: k8s.io/ingress-nginx
+
+ +

ClusterRole: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - endpoints
-  - nodes
-  - pods
-  - secrets
-  - namespaces
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - nodes
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

ClusterRoleBinding: nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: ClusterRole
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

Role: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-rules:
-- apiGroups:
-  - ""
-  resources:
-  - namespaces
-  verbs:
-  - get
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - pods
-  - secrets
-  - endpoints
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - ""
-  resources:
-  - services
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingresses/status
-  verbs:
-  - update
-- apiGroups:
-  - networking.k8s.io
-  resources:
-  - ingressclasses
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - coordination.k8s.io
-  resourceNames:
-  - nginx-ingress-ingress-nginx-leader
-  resources:
-  - leases
-  verbs:
-  - get
-  - update
-- apiGroups:
-  - coordination.k8s.io
-  resources:
-  - leases
-  verbs:
-  - create
-- apiGroups:
-  - ""
-  resources:
-  - events
-  verbs:
-  - create
-  - patch
-- apiGroups:
-  - discovery.k8s.io
-  resources:
-  - endpointslices
-  verbs:
-  - list
-  - watch
-  - get
+
+ +

RoleBinding: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
-roleRef:
-  apiGroup: rbac.authorization.k8s.io
-  kind: Role
-  name: nginx-ingress-ingress-nginx
-subjects:
-- kind: ServiceAccount
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +

ConfigMap: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + +
-apiVersion: v1
-data:
-  allow-snippet-annotations: "false"
-kind: ConfigMap
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller
-  namespace: default
-spec:
-  ipFamilies:
-  - IPv4
-  ipFamilyPolicy: SingleStack
-  ports:
-  - appProtocol: http
-    name: http
-    port: 80
-    protocol: TCP
-    targetPort: http
-  - appProtocol: https
-    name: https
-    port: 443
-    protocol: TCP
-    targetPort: https
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: LoadBalancer
+
+ +

Service: default/nginx-ingress-ingress-nginx-controller-admission

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
-apiVersion: v1
-kind: Service
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx-controller-admission
-  namespace: default
-spec:
-  ports:
-  - appProtocol: https
-    name: https-webhook
-    port: 443
-    targetPort: webhook
-  selector:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/name: ingress-nginx
-  type: ClusterIP
+
+ +

ServiceAccount: default/nginx-ingress-ingress-nginx

+
+ + + + + + + + + + + + + + + + +
-apiVersion: v1
-automountServiceAccountToken: true
-kind: ServiceAccount
-metadata:
-  labels:
-    app.kubernetes.io/component: controller
-    app.kubernetes.io/instance: nginx-ingress
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: ingress-nginx
-    app.kubernetes.io/part-of: ingress-nginx
-    app.kubernetes.io/version: 1.10.0
-    helm.sh/chart: ingress-nginx-4.10.0
-  name: nginx-ingress-ingress-nginx
-  namespace: default
+
+ +
+
+ +
_Stats_:
+[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs]
+
+ + diff --git a/integration-test/branch-9/target-5/output.md b/integration-test/branch-9/target-5/output.md new file mode 100644 index 00000000..25fde589 --- /dev/null +++ b/integration-test/branch-9/target-5/output.md @@ -0,0 +1,29 @@ +## Argo CD Diff Preview + +Summary: +```yaml + +``` + +
+Detailed Summary (9) + +```yaml +Deleted (9): +- app1 (-19) +- app1 (-19) +- app2 (-19) +- app2 (-19) +- custom-target-revision-example (-14) +- my-app-set-dev (-79) +- my-app-set-prod (-79) +- my-app-set-staging (-79) +- nginx-ingress (-470) +``` + +
+ +⚠️ Changes were found but `--max-diff-length` (400) is too small to display them. Increase the value or check the HTML output instead. + +_Stats_: +[Applications: 21], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs] diff --git a/integration-test/integration_test.go b/integration-test/integration_test.go index 5f01ea56..32b331c2 100644 --- a/integration-test/integration_test.go +++ b/integration-test/integration_test.go @@ -64,6 +64,7 @@ type TestCase struct { ArgocdConfigDir string // Custom argocd-config directory (relative to integration-test/); overrides auto-derived path ArgocdUIURL string // Argo CD URL for generating application links in diff output TraverseAppOfApps string // If "true", enables recursive child app discovery (--traverse-app-of-apps) + SummaryThreshold string // Collapse summary details when total changed apps exceed this value ExpectFailure bool // If true, the test is expected to fail } @@ -224,6 +225,28 @@ var testCases = []TestCase{ Suffix: "-3", MaxDiffLength: "400", }, + // Tests the collapsible summary feature with --summary-threshold=5. + // Branch-9 deletes 9 apps, so with threshold=5 the summary should collapse + // the app list behind a
block. + { + Name: "branch-9/target-4", + TargetBranch: "integration-test/branch-9/target", + BaseBranch: "integration-test/branch-9/base", + Suffix: "-4", + MaxDiffLength: "10000", + SummaryThreshold: "5", + }, + // Tests the collapsible summary combined with a tight max-diff-length. + // With threshold=5 the summary collapses, and max-diff-length=400 forces + // truncation of both the summary and diff sections. + { + Name: "branch-9/target-5", + TargetBranch: "integration-test/branch-9/target", + BaseBranch: "integration-test/branch-9/base", + Suffix: "-5", + MaxDiffLength: "400", + SummaryThreshold: "5", + }, { Name: "branch-10/target-1", TargetBranch: "integration-test/branch-10/target", @@ -904,6 +927,10 @@ func runWithDocker(tc TestCase, createCluster bool) error { args = append(args, "-e", "TRAVERSE_APP_OF_APPS=true") } + if tc.SummaryThreshold != "" { + args = append(args, "-e", fmt.Sprintf("SUMMARY_THRESHOLD=%s", tc.SummaryThreshold)) + } + // Add image (no additional args needed - all config is via env vars) args = append(args, *dockerImage) @@ -999,6 +1026,10 @@ func buildArgs(tc TestCase, createCluster bool) []string { args = append(args, "--traverse-app-of-apps") } + if tc.SummaryThreshold != "" { + args = append(args, "--summary-threshold", tc.SummaryThreshold) + } + // When the test requires cluster roles to be disabled (API mode or DisableClusterRoles flag), // pass --argocd-config-dir pointing at the no-cluster-roles directory (createClusterRoles: false). // If ArgocdConfigDir is explicitly set, use that directory instead. diff --git a/pkg/diff/generator.go b/pkg/diff/generator.go index f8a4b840..2c9b39e6 100644 --- a/pkg/diff/generator.go +++ b/pkg/diff/generator.go @@ -1,3 +1,6 @@ +// generator.go orchestrates diff generation and delegates to format-specific +// output files (markdown.go, html.go). Helper functions in this file should +// remain format-agnostic - avoid embedding HTML tags or Markdown syntax here. package diff import ( @@ -29,6 +32,7 @@ func GeneratePreview( statsInfo StatsInfo, selectionInfo SelectionInfo, argocdUIURL string, + summaryThreshold uint, ignoreResourceRules []resource_filter.IgnoreResourceRule, ) (time.Duration, error) { startTime := time.Now() @@ -64,7 +68,7 @@ func GeneratePreview( } // Build summary - summary := buildSummary(appDiffs) + fullSummary, compactSummary := buildSummary(appDiffs, int(summaryThreshold)) // Convert to markdown/HTML sections markdownSections, htmlSections := buildMatchingSections(appDiffs, argocdUIURL) @@ -72,11 +76,12 @@ func GeneratePreview( // Markdown log.Debug().Msg("Creating markdown output") markdownOutput := MarkdownOutput{ - title: title, - summary: summary, - sections: markdownSections, - statsInfo: statsInfo, - selectionInfo: selectionInfo, + title: title, + fullSummary: fullSummary, + compactSummary: compactSummary, + sections: markdownSections, + statsInfo: statsInfo, + selectionInfo: selectionInfo, } markdown := markdownOutput.printDiff(maxDiffMessageCharCount) markdownPath := fmt.Sprintf("%s/diff.md", outputFolder) @@ -89,11 +94,12 @@ func GeneratePreview( // HTML log.Debug().Msg("Creating html output") htmlOutput := HTMLOutput{ - title: title, - summary: summary, - sections: htmlSections, - statsInfo: statsInfo, - selectionInfo: selectionInfo, + title: title, + fullSummary: fullSummary, + compactSummary: compactSummary, + sections: htmlSections, + statsInfo: statsInfo, + selectionInfo: selectionInfo, } htmlDiff := htmlOutput.printDiff() htmlPath := fmt.Sprintf("%s/diff.html", outputFolder) @@ -108,14 +114,16 @@ func GeneratePreview( return time.Since(startTime), nil } -// buildSummary builds a summary string from AppDiffs -func buildSummary(diffs []matching.AppDiff) string { +// buildSummary returns (fullSummary, compactSummary). +// fullSummary is always the full application list with per-app details (plain text). +// compactSummary is a short counts-only summary returned when total > threshold; +// it is empty when the threshold is not exceeded. +// Pass threshold=0 to never produce a compact summary. +func buildSummary(diffs []matching.AppDiff, threshold int) (string, string) { if len(diffs) == 0 { - return "No changes found" + return "No changes found", "" } - var summaryBuilder strings.Builder - addedCount := 0 deletedCount := 0 modifiedCount := 0 @@ -131,42 +139,60 @@ func buildSummary(diffs []matching.AppDiff) string { } } + total := addedCount + deletedCount + modifiedCount + + var listBuilder strings.Builder + if addedCount > 0 { - fmt.Fprintf(&summaryBuilder, "Added (%d):\n", addedCount) + fmt.Fprintf(&listBuilder, "Added (%d):\n", addedCount) for _, d := range diffs { if d.Action == matching.ActionAdded { - fmt.Fprintf(&summaryBuilder, "+ %s%s\n", d.PrettyName(), d.ChangeStats()) + fmt.Fprintf(&listBuilder, "+ %s%s\n", d.PrettyName(), d.ChangeStats()) } } } if deletedCount > 0 { - // Add a newline before Deleted section if there was an Added section - if summaryBuilder.Len() > 0 { - fmt.Fprintln(&summaryBuilder) + if listBuilder.Len() > 0 { + fmt.Fprintln(&listBuilder) } - fmt.Fprintf(&summaryBuilder, "Deleted (%d):\n", deletedCount) + fmt.Fprintf(&listBuilder, "Deleted (%d):\n", deletedCount) for _, d := range diffs { if d.Action == matching.ActionDeleted { - fmt.Fprintf(&summaryBuilder, "- %s%s\n", d.PrettyName(), d.ChangeStats()) + fmt.Fprintf(&listBuilder, "- %s%s\n", d.PrettyName(), d.ChangeStats()) } } } if modifiedCount > 0 { - // Add a newline before Modified section if there was an Added or Deleted section - if summaryBuilder.Len() > 0 { - fmt.Fprintln(&summaryBuilder) + if listBuilder.Len() > 0 { + fmt.Fprintln(&listBuilder) } - fmt.Fprintf(&summaryBuilder, "Modified (%d):\n", modifiedCount) + fmt.Fprintf(&listBuilder, "Modified (%d):\n", modifiedCount) for _, d := range diffs { if d.Action == matching.ActionModified { - fmt.Fprintf(&summaryBuilder, "± %s%s\n", d.PrettyName(), d.ChangeStats()) + fmt.Fprintf(&listBuilder, "± %s%s\n", d.PrettyName(), d.ChangeStats()) } } } - return summaryBuilder.String() + if threshold > 0 && total > threshold { + var compact strings.Builder + fmt.Fprintf(&compact, "Total: %d applications changed\n", total) + if addedCount > 0 { + fmt.Fprintf(&compact, "Added: %d\n", addedCount) + } + if deletedCount > 0 { + fmt.Fprintf(&compact, "Deleted: %d\n", deletedCount) + } + if modifiedCount > 0 { + fmt.Fprintf(&compact, "Modified: %d\n", modifiedCount) + } + + return listBuilder.String(), compact.String() + } + + return listBuilder.String(), "" } // buildMatchingSections converts AppDiffs to markdown and HTML sections diff --git a/pkg/diff/generator_test.go b/pkg/diff/generator_test.go index 693a1554..9042ee34 100644 --- a/pkg/diff/generator_test.go +++ b/pkg/diff/generator_test.go @@ -10,16 +10,22 @@ import ( // Tests for buildMatchingSummary func TestBuildMatchingSummary_NoDiffs(t *testing.T) { - result := buildSummary(nil) - if result != "No changes found" { - t.Errorf("expected 'No changes found', got %q", result) + fullSummary, compactSummary := buildSummary(nil, 20) + if fullSummary != "No changes found" { + t.Errorf("expected 'No changes found', got %q", fullSummary) + } + if compactSummary != "" { + t.Errorf("expected no compact summary, got %q", compactSummary) } } func TestBuildMatchingSummary_EmptySlice(t *testing.T) { - result := buildSummary([]matching.AppDiff{}) - if result != "No changes found" { - t.Errorf("expected 'No changes found', got %q", result) + fullSummary, compactSummary := buildSummary([]matching.AppDiff{}, 20) + if fullSummary != "No changes found" { + t.Errorf("expected 'No changes found', got %q", fullSummary) + } + if compactSummary != "" { + t.Errorf("expected no compact summary, got %q", compactSummary) } } @@ -29,23 +35,26 @@ func TestBuildMatchingSummary_OnlyAdded(t *testing.T) { {NewName: "app-2", Action: matching.ActionAdded, AddedLines: 5}, } - result := buildSummary(diffs) + fullSummary, compactSummary := buildSummary(diffs, 20) - if !strings.Contains(result, "Added (2):") { - t.Errorf("expected 'Added (2):', got:\n%s", result) + if !strings.Contains(fullSummary, "Added (2):") { + t.Errorf("expected 'Added (2):', got:\n%s", fullSummary) + } + if !strings.Contains(fullSummary, "+ app-1 (+10)") { + t.Errorf("expected '+ app-1 (+10)', got:\n%s", fullSummary) } - if !strings.Contains(result, "+ app-1 (+10)") { - t.Errorf("expected '+ app-1 (+10)', got:\n%s", result) + if !strings.Contains(fullSummary, "+ app-2 (+5)") { + t.Errorf("expected '+ app-2 (+5)', got:\n%s", fullSummary) } - if !strings.Contains(result, "+ app-2 (+5)") { - t.Errorf("expected '+ app-2 (+5)', got:\n%s", result) + if compactSummary != "" { + t.Errorf("expected no compact summary, got:\n%s", compactSummary) } // Should NOT contain Deleted or Modified sections - if strings.Contains(result, "Deleted") { - t.Errorf("should not contain 'Deleted', got:\n%s", result) + if strings.Contains(fullSummary, "Deleted") { + t.Errorf("should not contain 'Deleted', got:\n%s", fullSummary) } - if strings.Contains(result, "Modified") { - t.Errorf("should not contain 'Modified', got:\n%s", result) + if strings.Contains(fullSummary, "Modified") { + t.Errorf("should not contain 'Modified', got:\n%s", fullSummary) } } @@ -54,13 +63,16 @@ func TestBuildMatchingSummary_OnlyDeleted(t *testing.T) { {OldName: "app-1", Action: matching.ActionDeleted, DeletedLines: 15}, } - result := buildSummary(diffs) + fullSummary, compactSummary := buildSummary(diffs, 20) - if !strings.Contains(result, "Deleted (1):") { - t.Errorf("expected 'Deleted (1):', got:\n%s", result) + if !strings.Contains(fullSummary, "Deleted (1):") { + t.Errorf("expected 'Deleted (1):', got:\n%s", fullSummary) } - if !strings.Contains(result, "- app-1 (-15)") { - t.Errorf("expected '- app-1 (-15)', got:\n%s", result) + if !strings.Contains(fullSummary, "- app-1 (-15)") { + t.Errorf("expected '- app-1 (-15)', got:\n%s", fullSummary) + } + if compactSummary != "" { + t.Errorf("expected no compact summary, got:\n%s", compactSummary) } } @@ -69,13 +81,16 @@ func TestBuildMatchingSummary_OnlyModified(t *testing.T) { {OldName: "app-1", NewName: "app-1", Action: matching.ActionModified, AddedLines: 3, DeletedLines: 2}, } - result := buildSummary(diffs) + fullSummary, compactSummary := buildSummary(diffs, 20) - if !strings.Contains(result, "Modified (1):") { - t.Errorf("expected 'Modified (1):', got:\n%s", result) + if !strings.Contains(fullSummary, "Modified (1):") { + t.Errorf("expected 'Modified (1):', got:\n%s", fullSummary) + } + if !strings.Contains(fullSummary, "± app-1 (+3|-2)") { + t.Errorf("expected '± app-1 (+3|-2)', got:\n%s", fullSummary) } - if !strings.Contains(result, "± app-1 (+3|-2)") { - t.Errorf("expected '± app-1 (+3|-2)', got:\n%s", result) + if compactSummary != "" { + t.Errorf("expected no compact summary, got:\n%s", compactSummary) } } @@ -86,16 +101,19 @@ func TestBuildMatchingSummary_MixedActions(t *testing.T) { {NewName: "new-app", Action: matching.ActionAdded, AddedLines: 12}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) - if !strings.Contains(result, "Added (1):") { - t.Errorf("expected 'Added (1):', got:\n%s", result) + if !strings.Contains(summary, "Added (1):") { + t.Errorf("expected 'Added (1):', got:\n%s", summary) } - if !strings.Contains(result, "Deleted (1):") { - t.Errorf("expected 'Deleted (1):', got:\n%s", result) + if !strings.Contains(summary, "Deleted (1):") { + t.Errorf("expected 'Deleted (1):', got:\n%s", summary) } - if !strings.Contains(result, "Modified (1):") { - t.Errorf("expected 'Modified (1):', got:\n%s", result) + if !strings.Contains(summary, "Modified (1):") { + t.Errorf("expected 'Modified (1):', got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) } } @@ -104,11 +122,14 @@ func TestBuildMatchingSummary_RenamedApp(t *testing.T) { {OldName: "old-name", NewName: "new-name", Action: matching.ActionModified, AddedLines: 1}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) // PrettyName for renamed app should show "old-name -> new-name" - if !strings.Contains(result, "± old-name -> new-name") { - t.Errorf("expected renamed app in summary, got:\n%s", result) + if !strings.Contains(summary, "± old-name -> new-name") { + t.Errorf("expected renamed app in summary, got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) } } @@ -118,11 +139,84 @@ func TestBuildMatchingSummary_NoChangeStats(t *testing.T) { {OldName: "app-1", NewName: "app-1", Action: matching.ActionModified}, } - result := buildSummary(diffs) + summary, details := buildSummary(diffs, 20) // ChangeStats() returns "" when both are 0, so just the name - if !strings.Contains(result, "± app-1\n") { - t.Errorf("expected 'app-1' without stats, got:\n%s", result) + if !strings.Contains(summary, "± app-1\n") { + t.Errorf("expected 'app-1' without stats, got:\n%s", summary) + } + if details != "" { + t.Errorf("expected no details, got:\n%s", details) + } +} + +func TestBuildMatchingSummary_CollapsesLargeSummary(t *testing.T) { + diffs := []matching.AppDiff{ + {NewName: "new-app", Action: matching.ActionAdded, AddedLines: 12}, + {OldName: "deleted-app", Action: matching.ActionDeleted, DeletedLines: 20}, + {OldName: "mod-app", NewName: "mod-app", Action: matching.ActionModified, AddedLines: 5, DeletedLines: 3}, + } + + fullSummary, compactSummary := buildSummary(diffs, 2) + + // compactSummary should contain only counts + expectedCompact := []string{ + "Total: 3 applications changed", + "Added: 1", + "Deleted: 1", + "Modified: 1", + } + for _, expected := range expectedCompact { + if !strings.Contains(compactSummary, expected) { + t.Errorf("expected compact summary to contain %q, got:\n%s", expected, compactSummary) + } + } + + // compactSummary should not contain per-app details + unexpectedCompact := []string{"+ new-app (+12)", "- deleted-app (-20)", "± mod-app (+5|-3)"} + for _, unexpected := range unexpectedCompact { + if strings.Contains(compactSummary, unexpected) { + t.Errorf("expected compact summary to omit %q, got:\n%s", unexpected, compactSummary) + } + } + + // fullSummary should contain the full per-app details (format-agnostic) + expectedFullSummary := []string{ + "Added (1):", + "+ new-app (+12)", + "Deleted (1):", + "- deleted-app (-20)", + "Modified (1):", + "± mod-app (+5|-3)", + } + for _, expected := range expectedFullSummary { + if !strings.Contains(fullSummary, expected) { + t.Errorf("expected full summary to contain %q, got:\n%s", expected, fullSummary) + } + } + + // Should not contain any markdown/HTML formatting + unexpectedFullSummary := []string{"
", "", "```"} + for _, unexpected := range unexpectedFullSummary { + if strings.Contains(fullSummary, unexpected) { + t.Errorf("expected full summary to be format-agnostic, but found %q in:\n%s", unexpected, fullSummary) + } + } +} + +func TestBuildMatchingSummary_ThresholdZeroAlwaysShowsInline(t *testing.T) { + diffs := []matching.AppDiff{ + {NewName: "app-1", Action: matching.ActionAdded, AddedLines: 10}, + {NewName: "app-2", Action: matching.ActionAdded, AddedLines: 5}, + } + + fullSummary, compactSummary := buildSummary(diffs, 0) + + if !strings.Contains(fullSummary, "+ app-1 (+10)") || !strings.Contains(fullSummary, "+ app-2 (+5)") { + t.Errorf("expected full summary, got:\n%s", fullSummary) + } + if compactSummary != "" { + t.Errorf("expected no compact summary when threshold is zero, got:\n%s", compactSummary) } } diff --git a/pkg/diff/html.go b/pkg/diff/html.go index 04a06629..b547a163 100644 --- a/pkg/diff/html.go +++ b/pkg/diff/html.go @@ -9,11 +9,12 @@ import ( ) type HTMLOutput struct { - title string - summary string - sections []HTMLSection - statsInfo StatsInfo - selectionInfo SelectionInfo + title string + fullSummary string // full application list with per-app details + compactSummary string // short counts-only summary; empty if not collapsed + sections []HTMLSection + statsInfo StatsInfo + selectionInfo SelectionInfo } const htmlTemplate = ` @@ -78,8 +79,8 @@ pre {

%title%

Summary:

-
%summary%
- +
%inline_summary%
+%detailed_summary_section%
%app_diffs%
@@ -110,10 +111,19 @@ func emptyReasonHTML(reason matching.EmptyReason) string { } } +// htmlDetailedSummarySection wraps the full summary in a collapsible
block. +func htmlDetailedSummarySection(fullSummary string, appCount int) string { + return fmt.Sprintf( + "\n
\n
\nDetailed Summary (%d)\n
%s
\n
\n
", + appCount, + html.EscapeString(strings.TrimSpace(fullSummary)), + ) +} + const htmlSectionTemplate = `
-%summary% +%inline_summary% %body%
@@ -137,7 +147,7 @@ func (h *HTMLSection) printHTMLSection() string { html.EscapeString(h.appName), html.EscapeString(h.filePath)) } - s = strings.ReplaceAll(s, "%summary%", summary) + s = strings.ReplaceAll(s, "%inline_summary%", summary) var body strings.Builder @@ -187,8 +197,21 @@ func (h *HTMLOutput) printDiff() string { sectionsDiff.WriteString("No changes found") } + // When compactSummary is set, use it as the inline summary and render the + // full summary in a collapsible
block via %detailed_summary_section%. + var inline_summary string + var detailedSummarySection string + + if h.compactSummary != "" { + inline_summary = h.compactSummary + detailedSummarySection = htmlDetailedSummarySection(h.fullSummary, len(h.sections)) + } else { + inline_summary = h.fullSummary + } + output := strings.ReplaceAll(htmlTemplate, "%title%", h.title) - output = strings.ReplaceAll(output, "%summary%", strings.TrimSpace(h.summary)) + output = strings.ReplaceAll(output, "%inline_summary%", strings.TrimSpace(inline_summary)) + output = strings.ReplaceAll(output, "%detailed_summary_section%", detailedSummarySection) output = strings.ReplaceAll(output, "%app_diffs%", strings.TrimSpace(sectionsDiff.String())) selection_changes := "" if s := h.selectionInfo.String(); s != "" { diff --git a/pkg/diff/html_test.go b/pkg/diff/html_test.go index 8ceb9cef..6d3ea61c 100644 --- a/pkg/diff/html_test.go +++ b/pkg/diff/html_test.go @@ -187,8 +187,8 @@ func TestHTMLSection_PrintHTMLSection_MultipleResources(t *testing.T) { func TestHTMLOutput_PrintDiff_Basic(t *testing.T) { output := HTMLOutput{ - title: "Test Diff", - summary: "Added (1):\n+ my-app (+10)", + title: "Test Diff", + fullSummary: "Added (1):\n+ my-app (+10)", sections: []HTMLSection{ { appName: "my-app", @@ -225,9 +225,9 @@ func TestHTMLOutput_PrintDiff_Basic(t *testing.T) { func TestHTMLOutput_PrintDiff_NoSections(t *testing.T) { output := HTMLOutput{ - title: "Empty Diff", - summary: "No changes", - sections: []HTMLSection{}, + title: "Empty Diff", + fullSummary: "No changes", + sections: []HTMLSection{}, } result := output.printDiff() @@ -239,9 +239,9 @@ func TestHTMLOutput_PrintDiff_NoSections(t *testing.T) { func TestHTMLOutput_PrintDiff_SelectionInfo(t *testing.T) { output := HTMLOutput{ - title: "Test", - summary: "Summary", - sections: []HTMLSection{}, + title: "Test", + fullSummary: "Summary", + sections: []HTMLSection{}, selectionInfo: SelectionInfo{ Base: AppSelectionInfo{SkippedApplications: 2, SkippedApplicationSets: 1}, Target: AppSelectionInfo{SkippedApplications: 5, SkippedApplicationSets: 1}, @@ -260,8 +260,8 @@ func TestHTMLOutput_PrintDiff_SelectionInfo(t *testing.T) { func TestHTMLOutput_PrintDiff_NoSelectionInfo(t *testing.T) { output := HTMLOutput{ - title: "Test", - summary: "Summary", + title: "Test", + fullSummary: "Summary", sections: []HTMLSection{ {appName: "app", filePath: "path", resources: []ResourceSection{{Header: "H", Content: "+x\n"}}}, }, @@ -281,8 +281,8 @@ func TestHTMLOutput_PrintDiff_NoSelectionInfo(t *testing.T) { func TestHTMLOutput_PrintDiff_TemplatePlaceholders(t *testing.T) { output := HTMLOutput{ - title: "My Title", - summary: "My Summary", + title: "My Title", + fullSummary: "My Summary", sections: []HTMLSection{ {appName: "app", filePath: "path", resources: []ResourceSection{{Header: "H", Content: "+x\n"}}}, }, @@ -292,7 +292,7 @@ func TestHTMLOutput_PrintDiff_TemplatePlaceholders(t *testing.T) { result := output.printDiff() // All placeholders should be replaced - placeholders := []string{"%title%", "%summary%", "%app_diffs%", "%selection_changes%", "%info_box%"} + placeholders := []string{"%title%", "%summary%", "%detailed_summary_section%", "%app_diffs%", "%selection_changes%", "%info_box%"} for _, p := range placeholders { if strings.Contains(result, p) { t.Errorf("placeholder %q was not replaced in output", p) @@ -302,8 +302,8 @@ func TestHTMLOutput_PrintDiff_TemplatePlaceholders(t *testing.T) { func TestHTMLOutput_PrintDiff_ValidHTML(t *testing.T) { output := HTMLOutput{ - title: "Test", - summary: "Summary", + title: "Test", + fullSummary: "Summary", sections: []HTMLSection{ {appName: "app", filePath: "path", resources: []ResourceSection{{Header: "H", Content: "+line\n"}}}, }, @@ -318,3 +318,54 @@ func TestHTMLOutput_PrintDiff_ValidHTML(t *testing.T) { t.Error("expected output to contain ") } } + +func TestHTMLOutput_PrintDiff_CollapsedAppList(t *testing.T) { + output := HTMLOutput{ + title: "Collapsed Test", + fullSummary: "Modified (2):\n± app-1 (+3|-2)\n± app-2 (+1|-1)\n", + compactSummary: "Total: 2 applications changed\nModified: 2", + sections: []HTMLSection{ + {appName: "app-1", filePath: "path/1", resources: []ResourceSection{{Header: "Deployment: app-1", Content: "+x\n"}}}, + {appName: "app-2", filePath: "path/2", resources: []ResourceSection{{Header: "Deployment: app-2", Content: "+y\n"}}}, + }, + } + + result := output.printDiff() + + // Should contain the collapsible details block + if !strings.Contains(result, "Detailed Summary (2)") { + t.Errorf("expected collapsible summary header, got:\n%s", result) + } + // App list should be inside a
 block
+	if !strings.Contains(result, "
Modified (2):") {
+		t.Errorf("expected app list in 
 block, got:\n%s", result)
+	}
+	// Content should be HTML-escaped
+	if !strings.Contains(result, "± app-1 (+3|-2)") {
+		t.Errorf("expected app-1 in collapsed details, got:\n%s", result)
+	}
+	// The per-app diff sections should still appear after the details block
+	if !strings.Contains(result, "app-1 (path/1)") {
+		t.Errorf("expected app-1 section, got:\n%s", result)
+	}
+	if !strings.Contains(result, "app-2 (path/2)") {
+		t.Errorf("expected app-2 section, got:\n%s", result)
+	}
+}
+
+func TestHTMLOutput_PrintDiff_NoCollapsedAppList(t *testing.T) {
+	output := HTMLOutput{
+		title:       "No Collapse",
+		fullSummary: "Modified (1):\n± app-1 (+3|-2)",
+		sections: []HTMLSection{
+			{appName: "app-1", filePath: "path/1", resources: []ResourceSection{{Header: "Deployment: app-1", Content: "+x\n"}}},
+		},
+	}
+
+	result := output.printDiff()
+
+	// Should NOT contain a collapsible details block for the app list
+	if strings.Contains(result, "Detailed Summary") {
+		t.Errorf("should not contain collapsible details when compactSummary is empty, got:\n%s", result)
+	}
+}
diff --git a/pkg/diff/markdown.go b/pkg/diff/markdown.go
index 97c3bb5c..ced48522 100644
--- a/pkg/diff/markdown.go
+++ b/pkg/diff/markdown.go
@@ -42,6 +42,15 @@ func markdownSectionFooter() string {
 	return "
\n\n" } +// markdownDetailedSummarySection wraps the full summary in a collapsible
block. +func markdownDetailedSummarySection(fullSummary string, appCount int) string { + return fmt.Sprintf( + "\n
\nDetailed Summary (%d)\n\n```yaml\n%s```\n\n
\n", + appCount, + fullSummary, + ) +} + var ( minSizeForSectionContent = 100 diffTooLongWarning = "\n🚨 Diff is too long" @@ -135,11 +144,12 @@ func (m *MarkdownSection) build(maxSize int) (string, bool) { } type MarkdownOutput struct { - title string - summary string - sections []MarkdownSection - statsInfo StatsInfo - selectionInfo SelectionInfo + title string + fullSummary string // full application list with per-app details + compactSummary string // short counts-only summary; empty if not collapsed + sections []MarkdownSection + statsInfo StatsInfo + selectionInfo SelectionInfo } const markdownTemplate = ` @@ -147,9 +157,9 @@ const markdownTemplate = ` Summary: ` + "```yaml" + ` -%summary% +%inline_summary% ` + "```" + ` - +%detailed_summary_section% %app_diffs% %selection_changes% %info_box% @@ -189,25 +199,37 @@ func (m *MarkdownOutput) printDiff(maxDiffMessageCharCount uint) string { output := strings.ReplaceAll(markdownTemplate, "%title%", m.title) output = strings.ReplaceAll(output, "%selection_changes%", selection_changes) - // temp value to check if summary was truncated, to decide whether to log a warning about it - var summary string + // When compactSummary is set, use it as the inline summary and render the + // full summary in a collapsible
block via %detailed_summary_section%. + + var inline_summary string + var detailedSummarySection string + + if m.compactSummary != "" { + inline_summary = m.compactSummary + detailedSummarySection = markdownDetailedSummarySection(m.fullSummary, len(m.sections)) + } else { + inline_summary = m.fullSummary + } + + output = strings.ReplaceAll(output, "%detailed_summary_section%", detailedSummarySection) // Truncate summary upfront if it would consume the entire budget if 0 < maxDiffMessageCharCount { - diffLengthWithoutSummary := len(strings.ReplaceAll(output, "%summary%", "")) + diffLengthWithoutSummary := len(strings.ReplaceAll(output, "%inline_summary%", "")) summaryBudget := int(maxDiffMessageCharCount) - diffLengthWithoutSummary - len(warningMessage) - infoBoxBufferSize - truncatedSummary, truncated := truncateSummary(m.summary, summaryBudget) + truncatedSummary, truncated := truncateSummary(inline_summary, summaryBudget) if truncated { log.Warn().Msgf("🚨 Markdown summary is too long, truncating to fit --max-diff-length (%d)", maxDiffMessageCharCount) - summary = truncatedSummary + inline_summary = truncatedSummary } else { - summary = strings.TrimSpace(m.summary) + inline_summary = strings.TrimSpace(inline_summary) } } else { - summary = strings.TrimSpace(m.summary) + inline_summary = strings.TrimSpace(inline_summary) } - output = strings.ReplaceAll(output, "%summary%", summary) + output = strings.ReplaceAll(output, "%inline_summary%", inline_summary) availableSpaceForDetailedDiff := int(maxDiffMessageCharCount) - len(output) - len(warningMessage) - infoBoxBufferSize diff --git a/pkg/diff/markdown_test.go b/pkg/diff/markdown_test.go index e0eb9efc..15e6532c 100644 --- a/pkg/diff/markdown_test.go +++ b/pkg/diff/markdown_test.go @@ -146,8 +146,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { { name: "Basic output with sections", output: MarkdownOutput{ - title: "Test Diff", - summary: "Added: 1\nModified: 1", + title: "Test Diff", + fullSummary: "Added: 1\nModified: 1", sections: []MarkdownSection{ { appName: "App 1", @@ -188,12 +188,42 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { "No changes found", }, }, + { + name: "Summary details render before app diffs", + output: MarkdownOutput{ + title: "Summary Details", + fullSummary: "Added (1):\n+ app-1 (+2)\n", + compactSummary: "Total: 3 applications changed\nAdded: 1\nModified: 2", + sections: []MarkdownSection{ + { + appName: "App 1", + filePath: "path/to/app1.yaml", + appURL: "", + resources: []ResourceSection{ + {Header: "@@ Application modified: App 1 @@", Content: "- old content\n+ new content"}, + }, + }, + }, + statsInfo: StatsInfo{ + ApplicationCount: 1, + }, + }, + maxDiffMessageCharCount: 5000, + expectedContains: []string{ + "Detailed Summary (1)", + "```yaml\nAdded (1):\n+ app-1 (+2)\n```", + "App 1 (path/to/app1.yaml)", + }, + expectedNotContains: []string{ + "No changes found", + }, + }, { name: "Empty sections shows no changes", output: MarkdownOutput{ - title: "Empty Diff", - summary: "No changes", - sections: []MarkdownSection{}, + title: "Empty Diff", + fullSummary: "No changes", + sections: []MarkdownSection{}, statsInfo: StatsInfo{ ApplicationCount: 0, }, @@ -212,8 +242,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { { name: "Extremely small max-diff-length shows helpful message instead of no changes found", output: MarkdownOutput{ - title: "Tiny Diff", - summary: "Some changes", + title: "Tiny Diff", + fullSummary: "Some changes", sections: []MarkdownSection{ { appName: "My App", @@ -240,8 +270,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { { name: "Truncated output shows warning", output: MarkdownOutput{ - title: "Large Diff", - summary: "Large changes", + title: "Large Diff", + fullSummary: "Large changes", sections: []MarkdownSection{ { appName: "Large App", @@ -291,6 +321,13 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { if !strings.HasSuffix(got, "\n") { t.Errorf("printDiff() should end with newline") } + + // Ensure no triple newlines from empty placeholders. + // Truncated sections can legitimately produce triple newlines + // (section header \n\n + warning \n), so skip this check then. + if !strings.Contains(got, "Diff exceeds max length") && strings.Contains(got, "\n\n\n") { + t.Errorf("printDiff() should not contain triple newlines, got:\n%s", got) + } }) } } @@ -298,8 +335,8 @@ func TestMarkdownOutput_PrintDiff(t *testing.T) { func TestMarkdownOutput_PrintDiff_EdgeCases(t *testing.T) { t.Run("Very small max size", func(t *testing.T) { output := MarkdownOutput{ - title: "Test", - summary: "Test summary", + title: "Test", + fullSummary: "Test summary", sections: []MarkdownSection{ { appName: "App", @@ -324,8 +361,8 @@ func TestMarkdownOutput_PrintDiff_EdgeCases(t *testing.T) { t.Run("Zero max size", func(t *testing.T) { output := MarkdownOutput{ - title: "Test", - summary: "Test summary", + title: "Test", + fullSummary: "Test summary", sections: []MarkdownSection{ { appName: "App", @@ -544,8 +581,8 @@ func TestMarkdownSection_Build_TruncationBehavior(t *testing.T) { func TestMarkdownOutput_TemplateReplacement(t *testing.T) { output := MarkdownOutput{ - title: "Custom Title", - summary: "Custom Summary\nWith Multiple Lines", + title: "Custom Title", + fullSummary: "Custom Summary\nWith Multiple Lines", sections: []MarkdownSection{ { appName: "Test Section", @@ -597,8 +634,8 @@ func TestMarkdownOutput_TemplateReplacement(t *testing.T) { func TestMarkdownOutput_SelectionChanges(t *testing.T) { t.Run("No selection changes when counts are equal", func(t *testing.T) { output := MarkdownOutput{ - title: "Test Diff", - summary: "Summary", + title: "Test Diff", + fullSummary: "Summary", sections: []MarkdownSection{ { appName: "App", @@ -626,8 +663,8 @@ func TestMarkdownOutput_SelectionChanges(t *testing.T) { t.Run("Shows selection changes when Application counts differ", func(t *testing.T) { output := MarkdownOutput{ - title: "Test Diff", - summary: "Summary", + title: "Test Diff", + fullSummary: "Summary", sections: []MarkdownSection{ { appName: "App", @@ -662,8 +699,8 @@ func TestMarkdownOutput_SelectionChanges(t *testing.T) { t.Run("Shows selection changes when ApplicationSet counts differ", func(t *testing.T) { output := MarkdownOutput{ - title: "Test Diff", - summary: "Summary", + title: "Test Diff", + fullSummary: "Summary", sections: []MarkdownSection{ { appName: "App", @@ -738,8 +775,8 @@ func TestMarkdownOutput_PrintDiff_TruncatesLargeSummary(t *testing.T) { summary := "Modified (11160):\n" + strings.Repeat("± spark-a--clark (+1)\n", 400) output := MarkdownOutput{ - title: "Large Summary Diff", - summary: summary, + title: "Large Summary Diff", + fullSummary: summary, sections: []MarkdownSection{ { appName: "spark-a--clark",