Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package nelm for openSUSE:Factory checked in at 2026-09-21 12:07:56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/nelm (Old) and /work/SRC/openSUSE:Factory/.nelm.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "nelm" Mon Sep 21 12:07:56 2026 rev:33 rq:1379169 version:1.26.2 Changes: -------- --- /work/SRC/openSUSE:Factory/nelm/nelm.changes 2026-07-21 23:11:52.609326299 +0200 +++ /work/SRC/openSUSE:Factory/.nelm.new.383539/nelm.changes 2026-09-21 12:08:00.996165091 +0200 @@ -1,0 +2,19 @@ +Sun Sep 20 05:22:42 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.26.2: + * Bug Fixes + - fix: `werf.io/deploy-dependency` didn't work if no readiness + operation in plan or if `namespace=releasens` +- Update to version 1.26.1: + * Bug Fixes + - fix: `werf.io/resource-policy` should only respect + skip-delete from live + +------------------------------------------------------------------- +Tue Aug 11 11:22:27 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.26.0: + * Features + - add werf.io/resource-policy annotation (5a2517f) + +------------------------------------------------------------------- Old: ---- nelm-1.25.3.obscpio New: ---- nelm-1.26.2.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ nelm.spec ++++++ --- /var/tmp/diff_new_pack.YknDgy/_old 2026-09-21 12:08:02.976248104 +0200 +++ /var/tmp/diff_new_pack.YknDgy/_new 2026-09-21 12:08:02.978248188 +0200 @@ -17,7 +17,7 @@ Name: nelm -Version: 1.25.3 +Version: 1.26.2 Release: 0 Summary: Helm 3 alternative License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.YknDgy/_old 2026-09-21 12:08:03.019249907 +0200 +++ /var/tmp/diff_new_pack.YknDgy/_new 2026-09-21 12:08:03.022250032 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/werf/nelm.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v1.25.3</param> + <param name="revision">refs/tags/v1.26.2</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.YknDgy/_old 2026-09-21 12:08:03.044250955 +0200 +++ /var/tmp/diff_new_pack.YknDgy/_new 2026-09-21 12:08:03.048251122 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/werf/nelm</param> <param name="changesrevision">9366af9135202b7d705c71fef6c7dbe0231c0345</param></service><service name="tar_scm"> <param name="url">https://github.com/werf/nelm.git</param> - <param name="changesrevision">5d3d03cb0b97bdb6df7a5b22320bfbf53edafdb4</param></service></servicedata> + <param name="changesrevision">6505774f6217ec78612d8ca6ccad1e5b3e2cea7f</param></service></servicedata> (No newline at EOF) ++++++ nelm-1.25.3.obscpio -> nelm-1.26.2.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/.pp/.gitignore new/nelm-1.26.2/.pp/.gitignore --- old/nelm-1.25.3/.pp/.gitignore 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/.pp/.gitignore 2026-07-16 10:38:47.000000000 +0200 @@ -1,2 +1,3 @@ state/ config.json +logs/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/CHANGELOG.md new/nelm-1.26.2/CHANGELOG.md --- old/nelm-1.25.3/CHANGELOG.md 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/CHANGELOG.md 2026-07-16 10:38:47.000000000 +0200 @@ -1,5 +1,33 @@ # Changelog +## [1.26.2](https://github.com/werf/nelm/compare/v1.26.1...v1.26.2) (2026-07-16) + + +### Bug Fixes + +* `werf.io/deploy-dependency` didn't work if no readiness operation in plan or if `namespace=releasens` ([4c9dd90](https://github.com/werf/nelm/commit/4c9dd90629b9e49563f74773c6df41ae504e23c6)) + +## [1.26.1](https://github.com/werf/nelm/compare/v1.26.0...v1.26.1) (2026-07-06) + + +### Bug Fixes + +* `werf.io/resource-policy` should only respect skip-delete from live ([ddfb620](https://github.com/werf/nelm/commit/ddfb620f23e2caae4c27c44bb85ebd4f82f0c4db)) + +## [1.26.0](https://github.com/werf/nelm/compare/v1.25.3...v1.26.0) (2026-07-03) + + +### Features + +* add `werf.io/resource-policy` annotation ([5a2517f](https://github.com/werf/nelm/commit/5a2517f7c0817716f0686de8fa26e97f23c95609)) + +## [1.25.3](https://github.com/werf/nelm/compare/v1.25.2...v1.25.3) (2026-06-24) + + +### Bug Fixes + +* HelmCompatibleTracking mode should allow annotations override ([6c7e6f4](https://github.com/werf/nelm/commit/6c7e6f429c37126160f0bb657904966ef4bef618)) + ## [1.25.3](https://github.com/werf/nelm/compare/v1.25.2...v1.25.3) (2026-06-24) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/common/common.go new/nelm-1.26.2/pkg/common/common.go --- old/nelm-1.25.3/pkg/common/common.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/common/common.go 2026-07-16 10:38:47.000000000 +0200 @@ -56,6 +56,17 @@ // The resource is owned by a single release. OwnershipRelease Ownership = "release" + // Skip creating the resource if it does not exist in the cluster. + ResourcePolicySkipCreate ResourcePolicy = "skip-create" + // Skip updating the resource if it already exists in the cluster. + ResourcePolicySkipUpdate ResourcePolicy = "skip-update" + // Skip recreating the resource when a recreation would otherwise be required. + ResourcePolicySkipRecreate ResourcePolicy = "skip-recreate" + // Skip deleting the resource (protect it from deletion). Alias: keep. + ResourcePolicySkipDelete ResourcePolicy = "skip-delete" + // Alias for skipDelete, matching the legacy helm.sh/resource-policy value. + ResourcePolicyKeep ResourcePolicy = "keep" + ResourceStateAbsent ResourceState = "absent" ResourceStatePresent ResourceState = "present" ResourceStateReady ResourceState = "ready" @@ -165,6 +176,8 @@ AnnotationKeyPatternHook = regexp.MustCompile(`^helm.sh/hook$`) AnnotationKeyHumanResourcePolicy = "helm.sh/resource-policy" AnnotationKeyPatternResourcePolicy = regexp.MustCompile(`^helm.sh/resource-policy$`) + AnnotationKeyHumanWerfResourcePolicy = "werf.io/resource-policy" + AnnotationKeyPatternWerfResourcePolicy = regexp.MustCompile(`^werf.io/resource-policy$`) AnnotationKeyHumanDeletePolicy = "werf.io/delete-policy" AnnotationKeyPatternDeletePolicy = regexp.MustCompile(`^werf.io/delete-policy$`) AnnotationKeyHumanHookDeletePolicy = "helm.sh/hook-delete-policy" @@ -242,6 +255,9 @@ // Configures resource deletions during deployment of this resource. type DeletePolicy string +// Configures which lifecycle operations are allowed for this resource. +type ResourcePolicy string + // Resource ownership. type Ownership string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/plan/export_test.go new/nelm-1.26.2/pkg/plan/export_test.go --- old/nelm-1.25.3/pkg/plan/export_test.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/plan/export_test.go 2026-07-16 10:38:47.000000000 +0200 @@ -1,6 +1,7 @@ package plan var ( - BuildInstallableResourceInfo = buildInstallableResourceInfo - BuildDeletableResourceInfo = buildDeletableResourceInfo + BuildInstallableResourceInfo = buildInstallableResourceInfo + BuildDeletableResourceInfo = buildDeletableResourceInfo + ForceReadinessTrackingForReadyDependencyTargets = forceReadinessTrackingForReadyDependencyTargets ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/plan/plan_build.go new/nelm-1.26.2/pkg/plan/plan_build.go --- old/nelm-1.25.3/pkg/plan/plan_build.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/plan/plan_build.go 2026-07-16 10:38:47.000000000 +0200 @@ -469,7 +469,7 @@ Iteration: OperationIteration(info.Iteration), Config: &OperationConfigTrackReadiness{ ResourceMeta: info.ResourceMeta, - FailMode: info.LocalResource.FailMode, + FailMode: info.FailMode, FailuresAllowed: info.LocalResource.FailuresAllowed, IgnoreLogs: info.LocalResource.SkipLogs, IgnoreLogsForContainers: info.LocalResource.SkipLogsForContainers, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/plan/ready_dependency_override_ai_test.go new/nelm-1.26.2/pkg/plan/ready_dependency_override_ai_test.go --- old/nelm-1.25.3/pkg/plan/ready_dependency_override_ai_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/nelm-1.26.2/pkg/plan/ready_dependency_override_ai_test.go 2026-07-16 10:38:47.000000000 +0200 @@ -0,0 +1,397 @@ +//go:build ai_tests + +package plan_test + +import ( + "context" + "testing" + "time" + + "github.com/samber/lo" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/werf/kubedog/pkg/trackers/rollout/multitrack" + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/kube" + "github.com/werf/nelm/pkg/kube/fake" + "github.com/werf/nelm/pkg/plan" + "github.com/werf/nelm/pkg/resource" + "github.com/werf/nelm/pkg/resource/spec" +) + +const ( + readyDepReleaseName = "test-release" + readyDepReleaseNamespace = "test-namespace" +) + +func TestAI_ReadyDependencyCrossStageDoesNotForceTracking(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), + multitrack.NonBlocking, + multitrack.IgnoreAndContinueDeployProcess, + ) + + dependent := readyDepInstallableResource( + readyDepConfigMapSpec("dependent", readyDepReleaseNamespace, nil), + multitrack.WaitUntilResourceReady, + multitrack.FailWholeDeployProcessImmediately, + ) + dependent.DeployConditions = map[common.On][]common.Stage{ + common.InstallOnInstall: {common.StagePostInstall}, + common.InstallOnUpgrade: {common.StagePostInstall}, + common.InstallOnRollback: {common.StagePostInstall}, + } + dependent.ManualInternalDependencies = []*resource.InternalDependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Names: []string{"target"}, + Groups: []string{""}, + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStateReady, + }, + } + + infos := buildReadyDepInfos(t, target, dependent, nil) + + targetInfo := findInfo(t, infos, "target") + require.False(t, targetInfo.MustTrackReadiness, + "a cross-stage state=ready dep must not force tracking, since no ordering edge can form") + require.Equal(t, multitrack.IgnoreAndContinueDeployProcess, targetInfo.FailMode, + "fail mode must remain the resource's own value when not forced") +} + +func TestAI_ReadyDependencyDoesNotForceCRDTarget(t *testing.T) { + crdInfo := &plan.InstallableResourceInfo{ + ResourceMeta: &spec.ResourceMeta{ + Name: "widgets.example.com", + GroupVersionKind: schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, + }, + LocalResource: &resource.InstallableResource{}, + MustInstall: plan.ResourceInstallTypeCreate, + Stage: common.StagePrePreInstall, + } + + // A Namespace also deploys in pre-pre-install, so it shares a stage with a CRD and can carry + // a ready dependency targeting one; the CRD must still be excluded from forced tracking. + dependentInfo := &plan.InstallableResourceInfo{ + ResourceMeta: &spec.ResourceMeta{ + Name: "my-namespace", + GroupVersionKind: schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, + }, + LocalResource: &resource.InstallableResource{ + ManualInternalDependencies: []*resource.InternalDependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Names: []string{"widgets.example.com"}, + Groups: []string{"apiextensions.k8s.io"}, + Kinds: []string{"CustomResourceDefinition"}, + }, + ResourceState: common.ResourceStateReady, + }, + }, + }, + Stage: common.StagePrePreInstall, + } + + plan.ForceReadinessTrackingForReadyDependencyTargets([]*plan.InstallableResourceInfo{crdInfo, dependentInfo}) + + require.False(t, crdInfo.MustTrackReadiness, + "a CRD target must never be forced to track even when a same-stage dependent selects it") + require.NotEqual(t, multitrack.FailWholeDeployProcessImmediately, crdInfo.FailMode) +} + +func TestAI_ReadyDependencyDoesNotForceSkipCreateAbsentTarget(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), + multitrack.NonBlocking, + multitrack.IgnoreAndContinueDeployProcess, + ) + target.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipCreate} + dependent := readyDependentResource("target", "") + + infos := buildReadyDepInfos(t, target, dependent, nil) + + targetInfo := findInfo(t, infos, "target") + require.Equal(t, plan.ResourceInstallTypeNone, targetInfo.MustInstall) + require.Nil(t, targetInfo.GetResult, "target is absent") + require.False(t, targetInfo.MustTrackReadiness, + "an absent skip-create target that will never be created must not be force-tracked") + require.Equal(t, multitrack.IgnoreAndContinueDeployProcess, targetInfo.FailMode, + "fail mode must remain the resource's own value when not forced") +} + +func TestAI_ReadyDependencyDoesNotForceUnmatchedTarget(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), + multitrack.NonBlocking, + multitrack.IgnoreAndContinueDeployProcess, + ) + dependent := readyDependentResource("some-other-name", "") + + infos := buildReadyDepInfos(t, target, dependent, nil) + + targetInfo := findInfo(t, infos, "target") + require.False(t, targetInfo.MustTrackReadiness, "NonBlocking target not selected by any dep stays untracked") + require.Equal(t, multitrack.IgnoreAndContinueDeployProcess, targetInfo.FailMode, + "fail mode must remain the resource's own value when not forced") +} + +func TestAI_ReadyDependencyForcesTrackingOnChartAuthoredNonBlockingTarget(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, map[string]string{ + "werf.io/track-termination-mode": "NonBlocking", + "werf.io/fail-mode": "IgnoreAndContinueDeployProcess", + }), + multitrack.NonBlocking, + multitrack.IgnoreAndContinueDeployProcess, + ) + dependent := readyDependentResource("target", "") + + infos := buildReadyDepInfos(t, target, dependent, nil) + + targetInfo := findInfo(t, infos, "target") + require.True(t, targetInfo.MustTrackReadiness) + require.Equal(t, multitrack.FailWholeDeployProcessImmediately, targetInfo.FailMode) +} + +func TestAI_ReadyDependencyForcesTrackingOnLegacyPatchedTarget(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), + multitrack.NonBlocking, + multitrack.IgnoreAndContinueDeployProcess, + ) + dependent := readyDependentResource("target", "") + + infos := buildReadyDepInfos(t, target, dependent, nil) + + targetInfo := findInfo(t, infos, "target") + require.True(t, targetInfo.MustTrackReadiness, "NonBlocking target selected by state=ready dep must be tracked") + require.Equal(t, multitrack.FailWholeDeployProcessImmediately, targetInfo.FailMode) +} + +func TestAI_ReadyDependencyForcesTrackingOnUnchangedTarget(t *testing.T) { + targetSpec := readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil) + target := readyDepInstallableResource(targetSpec, multitrack.NonBlocking, multitrack.IgnoreAndContinueDeployProcess) + dependent := readyDependentResource("target", "") + + infos := buildReadyDepInfos(t, target, dependent, func(cf *fake.ClientFactory) { + _, err := cf.KubeClient().Create(context.Background(), targetSpec, kube.KubeClientCreateOptions{ + DefaultNamespace: readyDepReleaseNamespace, + }) + require.NoError(t, err) + }) + + targetInfo := findInfo(t, infos, "target") + require.Equal(t, plan.ResourceInstallTypeNone, targetInfo.MustInstall, "target must be an unchanged no-op") + require.True(t, targetInfo.MustTrackReadiness, "unchanged target still forced to track when selected by state=ready dep") + require.Equal(t, multitrack.FailWholeDeployProcessImmediately, targetInfo.FailMode) +} + +func TestAI_ReadyDependencyProducesEdgeAndRetainsTrackingWithNoFinalTracking(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), + multitrack.NonBlocking, + multitrack.IgnoreAndContinueDeployProcess, + ) + dependent := readyDependentResource("target", "") + + infos := buildReadyDepInfos(t, target, dependent, nil) + + p, err := plan.BuildPlan(infos, nil, nil, plan.BuildPlanOptions{NoFinalTracking: true}) + require.NoError(t, err) + + trackOp := findTrackReadinessOp(t, p, "target") + createDependentOp := findCreateOp(t, p, "dependent") + createTargetOp := findCreateOp(t, p, "target") + + require.True(t, planHasEdge(t, p, createTargetOp.ID(), trackOp.ID()), + "target create must precede its track-readiness op") + require.True(t, planHasEdge(t, p, trackOp.ID(), createDependentOp.ID()), + "target track-readiness must precede dependent create (ready-dependency edge)") + + cfg := trackOp.Config.(*plan.OperationConfigTrackReadiness) + require.Equal(t, multitrack.FailWholeDeployProcessImmediately, cfg.FailMode, + "forced readiness op must fail the whole deploy despite IgnoreAndContinue annotation") +} + +func TestAI_ReadyDependencyReleaseNamespaceSelectorProducesEdge(t *testing.T) { + cf, err := fake.NewClientFactory(context.Background()) + require.NoError(t, err) + + targetSpec := readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil) + target, err := resource.NewInstallableResource(targetSpec, nil, readyDepReleaseNamespace, cf, resource.InstallableResourceOptions{}) + require.NoError(t, err) + + dependentSpec := readyDepConfigMapSpec("dependent", readyDepReleaseNamespace, map[string]string{ + "werf.io/deploy-dependency-target": "state=ready,version=v1,kind=ConfigMap,name=target,namespace=" + readyDepReleaseNamespace, + }) + dependent, err := resource.NewInstallableResource(dependentSpec, nil, readyDepReleaseNamespace, cf, resource.InstallableResourceOptions{}) + require.NoError(t, err) + + instInfos, _, err := plan.BuildResourceInfos( + context.Background(), + common.DeployTypeInitial, + readyDepReleaseName, + readyDepReleaseNamespace, + []*resource.InstallableResource{target, dependent}, + nil, + false, + cf, + plan.BuildResourceInfosOptions{NetworkParallelism: 10}, + ) + require.NoError(t, err) + + p, err := plan.BuildPlan(instInfos, nil, nil, plan.BuildPlanOptions{NoFinalTracking: true}) + require.NoError(t, err) + + trackOp := findTrackReadinessOp(t, p, "target") + createDependentOp := findCreateOp(t, p, "dependent") + + require.True(t, planHasEdge(t, p, trackOp.ID(), createDependentOp.ID()), + "a namespace=<release-ns> selector must normalize to the target's empty namespace and produce the ready-dependency edge") +} + +func readyDependentResource(targetName, targetNamespace string) *resource.InstallableResource { + res := readyDepInstallableResource( + readyDepConfigMapSpec("dependent", readyDepReleaseNamespace, nil), + multitrack.WaitUntilResourceReady, + multitrack.FailWholeDeployProcessImmediately, + ) + res.ManualInternalDependencies = []*resource.InternalDependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Names: []string{targetName}, + Namespaces: []string{targetNamespace}, + Groups: []string{""}, + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStateReady, + }, + } + + return res +} + +func buildReadyDepInfos(t *testing.T, target, dependent *resource.InstallableResource, prepare func(cf *fake.ClientFactory)) []*plan.InstallableResourceInfo { + t.Helper() + + cf, err := fake.NewClientFactory(context.Background()) + require.NoError(t, err) + + if prepare != nil { + prepare(cf) + } + + instInfos, _, err := plan.BuildResourceInfos( + context.Background(), + common.DeployTypeInitial, + readyDepReleaseName, + readyDepReleaseNamespace, + []*resource.InstallableResource{target, dependent}, + nil, + false, + cf, + plan.BuildResourceInfosOptions{NetworkParallelism: 10}, + ) + require.NoError(t, err) + + return instInfos +} + +func findCreateOp(t *testing.T, p *plan.Plan, name string) *plan.Operation { + t.Helper() + + op, found := lo.Find(p.Operations(), func(op *plan.Operation) bool { + cfg, ok := op.Config.(*plan.OperationConfigCreate) + + return ok && cfg.ResourceSpec.Name == name + }) + require.Truef(t, found, "create op for %q not found", name) + + return op +} + +func findInfo(t *testing.T, infos []*plan.InstallableResourceInfo, name string) *plan.InstallableResourceInfo { + t.Helper() + + info, found := lo.Find(infos, func(i *plan.InstallableResourceInfo) bool { + return i.Name == name + }) + require.Truef(t, found, "info for %q not found", name) + + return info +} + +func findTrackReadinessOp(t *testing.T, p *plan.Plan, name string) *plan.Operation { + t.Helper() + + op, found := lo.Find(p.Operations(), func(op *plan.Operation) bool { + cfg, ok := op.Config.(*plan.OperationConfigTrackReadiness) + + return ok && cfg.ResourceMeta.Name == name + }) + require.Truef(t, found, "track-readiness op for %q not found", name) + + return op +} + +func planHasEdge(t *testing.T, p *plan.Plan, fromID, toID string) bool { + t.Helper() + + adjMap, err := p.Graph.AdjacencyMap() + require.NoError(t, err) + + _, ok := adjMap[fromID][toID] + + return ok +} + +func readyDepConfigMapSpec(name, namespace string, annotations map[string]string) *spec.ResourceSpec { + meta := map[string]interface{}{ + "name": name, + "namespace": namespace, + "annotations": map[string]interface{}{ + "meta.helm.sh/release-name": readyDepReleaseName, + "meta.helm.sh/release-namespace": readyDepReleaseNamespace, + }, + "labels": map[string]interface{}{ + "app.kubernetes.io/managed-by": "Helm", + }, + } + anns := meta["annotations"].(map[string]interface{}) + for k, v := range annotations { + anns[k] = v + } + + resSpec := spec.NewResourceSpec(&unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": meta, + "data": map[string]interface{}{"key": "value"}, + }, + }, namespace, spec.ResourceSpecOptions{}) + resSpec.Unstruct.SetNamespace(namespace) + + return resSpec +} + +func readyDepInstallableResource(resSpec *spec.ResourceSpec, trackTermination multitrack.TrackTerminationMode, failMode multitrack.FailMode) *resource.InstallableResource { + return &resource.InstallableResource{ + ResourceSpec: resSpec, + Ownership: common.OwnershipRelease, + FailMode: failMode, + NoActivityTimeout: 4 * time.Minute, + ShowLogsOnlyForNumberOfReplicas: 1, + TrackTerminationMode: trackTermination, + Weight: lo.ToPtr(0), + DeployConditions: map[common.On][]common.Stage{ + common.InstallOnInstall: {common.StageInstall}, + common.InstallOnUpgrade: {common.StageInstall}, + common.InstallOnRollback: {common.StageInstall}, + }, + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/plan/resource_info.go new/nelm-1.26.2/pkg/plan/resource_info.go --- old/nelm-1.25.3/pkg/plan/resource_info.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/plan/resource_info.go 2026-07-16 10:38:47.000000000 +0200 @@ -57,6 +57,7 @@ MustDeleteOnSuccessfulInstall bool `json:"mustDeleteOnSuccessfulInstall"` MustDeleteOnFailedInstall bool `json:"mustDeleteOnFailedInstall"` MustTrackReadiness bool `json:"mustTrackReadiness"` + FailMode multitrack.FailMode `json:"failMode"` Stage common.Stage `json:"stage"` StageDeleteOnSuccessfulInstall common.Stage `json:"stageDeleteOnSuccessfulInstall,omitempty"` @@ -127,6 +128,8 @@ return nil, nil, fmt.Errorf("wait for prev release resource pool: %w", err) } + forceReadinessTrackingForReadyDependencyTargets(instResourceInfos) + sort.SliceStable(instResourceInfos, func(i, j int) bool { return InstallableResourceInfoSortByStageHandler(instResourceInfos[i], instResourceInfos[j]) }) @@ -177,57 +180,50 @@ DefaultNamespace: releaseNamespace, TryCache: true, }) - if getErr != nil { - if kube.IsNotFoundErr(getErr) || kube.IsNoSuchKindErr(getErr) { - mustDeleteOnSuccess := mustDeleteOnSuccessfulDeploy(localRes, nil, ResourceInstallTypeCreate, releaseNamespace) - trackReadiness := mustTrackReadiness(localRes, ResourceInstallTypeCreate, false, prevRelFailed, mustDeleteOnSuccess) - - return lo.Map(stages, func(stg common.Stage, _ int) *InstallableResourceInfo { - return &InstallableResourceInfo{ - ResourceMeta: localRes.ResourceMeta, - LocalResource: localRes, - MustDeleteOnFailedInstall: mustDeleteOnFailedDeploy(localRes, nil, ResourceInstallTypeCreate, releaseNamespace, trackReadiness), - MustDeleteOnSuccessfulInstall: mustDeleteOnSuccess, - MustInstall: ResourceInstallTypeCreate, - MustTrackReadiness: trackReadiness, - Stage: stg, - StageDeleteOnSuccessfulInstall: stageDeleteOnSuccessfulInstall(mustDeleteOnSuccess, stg), - } - }), nil - } else { - return nil, fmt.Errorf("get resource %q: %w", localRes.IDHuman(), getErr) - } + if getErr != nil && !kube.IsNotFoundErr(getErr) && !kube.IsNoSuchKindErr(getErr) { + return nil, fmt.Errorf("get resource %q: %w", localRes.IDHuman(), getErr) } - var err error - - getObj, err = fixManagedFieldsInCluster(ctx, releaseNamespace, getObj, localRes, noRemoveManualChanges, clientFactory, lastDeployedOrLastRelResSpecs) - if err != nil { - return nil, fmt.Errorf("fix managed fields for resource %q: %w", localRes.IDHuman(), err) + var ( + getMeta *spec.ResourceMeta + dryApplyObj *unstructured.Unstructured + dryApplyErr error + resourcePolicies = localRes.ResourcePolicies + ) + if getErr == nil { + var err error + + getObj, err = fixManagedFieldsInCluster(ctx, releaseNamespace, getObj, localRes, noRemoveManualChanges, clientFactory, lastDeployedOrLastRelResSpecs) + if err != nil { + return nil, fmt.Errorf("fix managed fields for resource %q: %w", localRes.IDHuman(), err) + } + + getMeta = spec.NewResourceMetaFromUnstructured(getObj, releaseNamespace, localRes.FilePath) + resourcePolicies = resource.ResolveResourcePolicies(localRes, getMeta, releaseNamespace) + + dryApplyObj, dryApplyErr = clientFactory.KubeClient().Apply(ctx, localRes.ResourceSpec, kube.KubeClientApplyOptions{ + DefaultNamespace: releaseNamespace, + DryRun: true, + }) } - dryApplyObj, dryApplyErr := clientFactory.KubeClient().Apply(ctx, localRes.ResourceSpec, kube.KubeClientApplyOptions{ - DefaultNamespace: releaseNamespace, - DryRun: true, - }) - - installType, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr) + installType, skippedByPolicy, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr, resourcePolicies) if err != nil { return nil, fmt.Errorf("determine install type for resource %q: %w", localRes.IDHuman(), err) } - getMeta := spec.NewResourceMetaFromUnstructured(getObj, releaseNamespace, localRes.FilePath) - mustDeleteOnSuccess := mustDeleteOnSuccessfulDeploy(localRes, getMeta, installType, releaseNamespace) - trackReadiness := mustTrackReadiness(localRes, installType, true, prevRelFailed, mustDeleteOnSuccess) + mustDeleteOnSuccess := mustDeleteOnSuccessfulDeploy(localRes, getMeta, installType, releaseNamespace, skippedByPolicy) + trackReadiness := mustTrackReadiness(localRes, installType, getObj != nil, prevRelFailed, mustDeleteOnSuccess, skippedByPolicy) return lo.Map(stages, func(stg common.Stage, _ int) *InstallableResourceInfo { return &InstallableResourceInfo{ ResourceMeta: localRes.ResourceMeta, DryApplyErr: dryApplyErr, DryApplyResult: dryApplyObj, + FailMode: localRes.FailMode, GetResult: getObj, LocalResource: localRes, - MustDeleteOnFailedInstall: mustDeleteOnFailedDeploy(localRes, getMeta, installType, releaseNamespace, trackReadiness), + MustDeleteOnFailedInstall: mustDeleteOnFailedDeploy(localRes, getMeta, installType, releaseNamespace, trackReadiness, skippedByPolicy), MustDeleteOnSuccessfulInstall: mustDeleteOnSuccess, MustInstall: installType, MustTrackReadiness: trackReadiness, @@ -358,7 +354,7 @@ Stage: stage, } - if localRes.KeepOnDelete || localRes.Ownership == common.OwnershipAnyone { + if lo.Contains(localRes.ResourcePolicies, common.ResourcePolicySkipDelete) || localRes.Ownership == common.OwnershipAnyone { return noDeleteInfo, nil } @@ -380,10 +376,8 @@ if err := resource.ValidateResourcePolicy(getMeta); err != nil { return noDeleteInfo, nil - } else { - if keep := resource.KeepOnDelete(getMeta, releaseNamespace); keep { - return noDeleteInfo, nil - } + } else if lo.Contains(resource.ResourcePolicies(getMeta, releaseNamespace), common.ResourcePolicySkipDelete) { + return noDeleteInfo, nil } if orphaned(getMeta, releaseName, releaseNamespace) { @@ -584,6 +578,46 @@ return nil } +func forceReadinessTrackingForReadyDependencyTargets(infos []*InstallableResourceInfo) { + type readyMatcher struct { + matcher *spec.ResourceMatcher + stage common.Stage + } + + var readyMatchers []readyMatcher + for _, info := range infos { + for _, dep := range info.LocalResource.ManualInternalDependencies { + if dep.ResourceState == common.ResourceStateReady { + readyMatchers = append(readyMatchers, readyMatcher{matcher: dep.ResourceMatcher, stage: info.Stage}) + } + } + } + + if len(readyMatchers) == 0 { + return + } + + for _, info := range infos { + if spec.IsCRD(info.GroupVersionKind.GroupKind()) { + continue + } + + if info.MustInstall == ResourceInstallTypeNone && info.GetResult == nil { + continue + } + + matched := lo.SomeBy(readyMatchers, func(rm readyMatcher) bool { + return rm.stage == info.Stage && rm.matcher.Match(info.ResourceMeta) + }) + if !matched { + continue + } + + info.MustTrackReadiness = true + info.FailMode = multitrack.FailWholeDeployProcessImmediately + } +} + func getManagedFieldPathFromSpecPath(subPath []string) []string { path := make([]string, len(subPath)) @@ -676,16 +710,17 @@ return iterInfo.ID() == inf.ID() && inf.Iteration == iterInfo.Iteration-1 })) - if prevIterInfo.MustDeleteOnSuccessfulInstall { + if prevIterInfo.MustDeleteOnSuccessfulInstall && !lo.Contains(iterInfo.LocalResource.ResourcePolicies, common.ResourcePolicySkipCreate) { iterInfo.MustInstall = ResourceInstallTypeCreate } } } } -func mustDeleteOnFailedDeploy(res *resource.InstallableResource, getMeta *spec.ResourceMeta, installType ResourceInstallType, releaseNamespace string, mustTrackReadiness bool) bool { - if !res.DeleteOnFailed || - res.KeepOnDelete || +func mustDeleteOnFailedDeploy(res *resource.InstallableResource, getMeta *spec.ResourceMeta, installType ResourceInstallType, releaseNamespace string, mustTrackReadiness, skippedByPolicy bool) bool { + if skippedByPolicy || + !res.DeleteOnFailed || + lo.Contains(res.ResourcePolicies, common.ResourcePolicySkipDelete) || installType == ResourceInstallTypeNone || !mustTrackReadiness { return false @@ -694,28 +729,26 @@ if getMeta != nil { if err := resource.ValidateResourcePolicy(getMeta); err != nil { return false - } else { - if keep := resource.KeepOnDelete(getMeta, releaseNamespace); keep { - return false - } + } else if lo.Contains(resource.ResourcePolicies(getMeta, releaseNamespace), common.ResourcePolicySkipDelete) { + return false } } return true } -func mustDeleteOnSuccessfulDeploy(localRes *resource.InstallableResource, getMeta *spec.ResourceMeta, installType ResourceInstallType, releaseNamespace string) bool { - if !localRes.DeleteOnSucceeded || localRes.KeepOnDelete { +func mustDeleteOnSuccessfulDeploy(localRes *resource.InstallableResource, getMeta *spec.ResourceMeta, installType ResourceInstallType, releaseNamespace string, skippedByPolicy bool) bool { + if skippedByPolicy || + !localRes.DeleteOnSucceeded || + lo.Contains(localRes.ResourcePolicies, common.ResourcePolicySkipDelete) { return false } if getMeta != nil { if err := resource.ValidateResourcePolicy(getMeta); err != nil { return false - } else { - if keep := resource.KeepOnDelete(getMeta, releaseNamespace); keep { - return false - } + } else if lo.Contains(resource.ResourcePolicies(getMeta, releaseNamespace), common.ResourcePolicySkipDelete) { + return false } } @@ -726,8 +759,9 @@ return true } -func mustTrackReadiness(res *resource.InstallableResource, resInstallType ResourceInstallType, exists, prevRelFailed, mustDeleteOnSuccessfulInstall bool) bool { - if spec.IsCRD(res.Unstruct.GroupVersionKind().GroupKind()) || +func mustTrackReadiness(res *resource.InstallableResource, resInstallType ResourceInstallType, exists, prevRelFailed, mustDeleteOnSuccessfulInstall, skippedByPolicy bool) bool { + if skippedByPolicy || + spec.IsCRD(res.Unstruct.GroupVersionKind().GroupKind()) || res.TrackTerminationMode == multitrack.NonBlocking { return false } @@ -797,18 +831,39 @@ return newManagedFields, newOursEntry, changed } -func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error) (ResourceInstallType, error) { +func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error, resourcePolicies []common.ResourcePolicy) (installType ResourceInstallType, skippedByPolicy bool, err error) { + skipCreate := lo.Contains(resourcePolicies, common.ResourcePolicySkipCreate) + skipUpdate := lo.Contains(resourcePolicies, common.ResourcePolicySkipUpdate) + skipRecreate := lo.Contains(resourcePolicies, common.ResourcePolicySkipRecreate) + + if getObj == nil { + if skipCreate { + return ResourceInstallTypeNone, true, nil + } + + return ResourceInstallTypeCreate, false, nil + } + isImmutable := dryApplyErr != nil && kube.IsImmutableErr(dryApplyErr) + + if skipRecreate && (localRes.Recreate || (isImmutable && (localRes.RecreateOnImmutable || !localRes.Recreate))) { + return ResourceInstallTypeNone, true, nil + } + if isImmutable && !localRes.Recreate && !localRes.RecreateOnImmutable { - return "", fmt.Errorf("immutable fields change in resource %q, but recreation is not requested: %w", localRes.IDHuman(), dryApplyErr) + return "", false, fmt.Errorf("immutable fields change in resource %q, but recreation is not requested: %w", localRes.IDHuman(), dryApplyErr) } if localRes.Recreate || (isImmutable && localRes.RecreateOnImmutable) { - return ResourceInstallTypeRecreate, nil + return ResourceInstallTypeRecreate, false, nil } if dryApplyErr != nil { - return ResourceInstallTypeApply, nil + if skipCreate || skipUpdate { + return ResourceInstallTypeNone, true, nil + } + + return ResourceInstallTypeApply, false, nil } diffableGetObj := spec.CleanUnstruct(getObj, spec.CleanUnstructOptions{ @@ -824,14 +879,18 @@ }) if patch, err := jsondiff.Compare(diffableGetObj, diffableDryApplyObj); err != nil { - return "", fmt.Errorf("compare live and dry-apply versions of resource %q: %w", localRes.IDHuman(), err) + return "", false, fmt.Errorf("compare live and dry-apply versions of resource %q: %w", localRes.IDHuman(), err) } else if len(patch) > 0 { + if skipUpdate { + return ResourceInstallTypeNone, true, nil + } + log.Default.Trace(ctx, "Get/DryApply patch for %q: %s", localRes.IDHuman(), patch.String()) - return ResourceInstallTypeUpdate, nil + return ResourceInstallTypeUpdate, false, nil } - return ResourceInstallTypeNone, nil + return ResourceInstallTypeNone, false, nil } func stageDeleteOnSuccessfulInstall(shouldDelete bool, installStg common.Stage) common.Stage { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/plan/resource_info_test.go new/nelm-1.26.2/pkg/plan/resource_info_test.go --- old/nelm-1.25.3/pkg/plan/resource_info_test.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/plan/resource_info_test.go 2026-07-16 10:38:47.000000000 +0200 @@ -91,7 +91,7 @@ }, input: func() (*resource.DeletableResource, common.DeployType) { localRes := defaultDeletableResource(s.releaseName, s.releaseNamespace) - localRes.KeepOnDelete = true + localRes.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} return localRes, common.DeployTypeUninstall }, @@ -429,6 +429,71 @@ }, name: `for non-existing resource, uninstall deploy`, }, + { + expect: func(localRes *resource.InstallableResource) []*plan.InstallableResourceInfo { + info := defaultInstallableResourceInfo(localRes) + info.MustInstall = plan.ResourceInstallTypeNone + info.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{info} + }, + input: func() (*resource.InstallableResource, common.DeployType, bool) { + localRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + localRes.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipCreate} + + return localRes, common.DeployTypeInitial, false + }, + name: `for non-existing resource with skip-create, initial deploy`, + }, + { + expect: func(localRes *resource.InstallableResource) []*plan.InstallableResourceInfo { + info := defaultInstallableResourceInfo(localRes) + info.GetResult = defaultResourceSpec(s.releaseName, s.releaseNamespace).Unstruct + info.DryApplyResult = updatedResourceSpec(&s.Suite, s.releaseName, s.releaseNamespace).Unstruct + info.MustInstall = plan.ResourceInstallTypeNone + info.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{info} + }, + input: func() (*resource.InstallableResource, common.DeployType, bool) { + localRes := updatedInstallableResource(&s.Suite, s.releaseName, s.releaseNamespace) + localRes.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipUpdate} + + return localRes, common.DeployTypeInitial, false + }, + name: `for outdated resource with skip-update, initial deploy`, + prepare: func() { + _, err := s.clientFactory.KubeClient().Create(context.Background(), defaultResourceSpec(s.releaseName, s.releaseNamespace), kube.KubeClientCreateOptions{ + DefaultNamespace: s.releaseNamespace, + }) + s.Require().NoError(err) + }, + }, + { + expect: func(localRes *resource.InstallableResource) []*plan.InstallableResourceInfo { + info := defaultInstallableResourceInfo(localRes) + info.GetResult = defaultResourceSpec(s.releaseName, s.releaseNamespace).Unstruct + info.DryApplyResult = updatedResourceSpec(&s.Suite, s.releaseName, s.releaseNamespace).Unstruct + info.MustInstall = plan.ResourceInstallTypeNone + info.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{info} + }, + input: func() (*resource.InstallableResource, common.DeployType, bool) { + localRes := updatedInstallableResource(&s.Suite, s.releaseName, s.releaseNamespace) + localRes.Recreate = true + localRes.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipRecreate} + + return localRes, common.DeployTypeInitial, false + }, + name: `for outdated resource with skip-recreate, initial deploy`, + prepare: func() { + _, err := s.clientFactory.KubeClient().Create(context.Background(), defaultResourceSpec(s.releaseName, s.releaseNamespace), kube.KubeClientCreateOptions{ + DefaultNamespace: s.releaseNamespace, + }) + s.Require().NoError(err) + }, + }, } for _, tc := range testCases { @@ -567,6 +632,7 @@ LocalResource: localRes, MustInstall: plan.ResourceInstallTypeCreate, MustTrackReadiness: true, + FailMode: localRes.FailMode, Stage: common.StageInstall, } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/resource/metadata.go new/nelm-1.26.2/pkg/resource/metadata.go --- old/nelm-1.25.3/pkg/resource/metadata.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/resource/metadata.go 2026-07-16 10:38:47.000000000 +0200 @@ -26,27 +26,54 @@ "github.com/werf/nelm/pkg/util" ) -func KeepOnDelete(meta *spec.ResourceMeta, releaseNamespace string) bool { - if spec.IsReleaseNamespace(meta.Name, meta.GroupVersionKind, releaseNamespace) { - return true +func ResourcePolicies(meta *spec.ResourceMeta, releaseNamespace string) []common.ResourcePolicy { + var policies []common.ResourcePolicy + if _, value, ok := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternWerfResourcePolicy); ok { + policies = parseResourcePolicies(value) + } else if _, value, ok := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternResourcePolicy); ok { + policies = parseResourcePolicies(value) } - _, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternResourcePolicy) - if !found { - return false + if spec.IsReleaseNamespace(meta.Name, meta.GroupVersionKind, releaseNamespace) && !lo.Contains(policies, common.ResourcePolicySkipDelete) { + policies = append(policies, common.ResourcePolicySkipDelete) } - return value == "keep" + return policies } func ValidateResourcePolicy(meta *spec.ResourceMeta) error { + if key, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternWerfResourcePolicy); found { + if value == "" { + return fmt.Errorf("invalid value %q for annotation %q, expected non-empty string value", value, key) + } + + for _, policy := range strings.Split(value, ",") { + policy = strings.TrimSpace(policy) + if policy == "" { + return fmt.Errorf("invalid value %q for annotation %q, one of the comma-separated values is empty", value, key) + } + + switch common.ResourcePolicy(policy) { + case common.ResourcePolicySkipCreate, + common.ResourcePolicySkipUpdate, + common.ResourcePolicySkipRecreate, + common.ResourcePolicySkipDelete, + common.ResourcePolicyKeep: + default: + return fmt.Errorf("invalid unknown value %q for annotation %q", policy, key) + } + } + + return nil + } + if key, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternResourcePolicy); found { if value == "" { return fmt.Errorf("invalid value %q for annotation %q, expected non-empty string value", value, key) } - switch value { - case "keep": + switch common.ResourcePolicy(value) { + case common.ResourcePolicyKeep: default: return fmt.Errorf("invalid unknown value %q for annotation %q", value, key) } @@ -521,7 +548,7 @@ return lo.Values(deps) } -func manualInternalDeployDependencies(meta *spec.ResourceMeta) []*InternalDependency { +func manualInternalDeployDependencies(meta *spec.ResourceMeta, releaseNamespace string) []*InternalDependency { if spec.IsCRD(meta.GroupVersionKind.GroupKind()) { return nil } @@ -555,6 +582,10 @@ depNamespace = valParts[2] } + if depNamespace == releaseNamespace { + depNamespace = "" + } + depName := valParts[len(valParts)-1] dep := &InternalDependency{ @@ -585,7 +616,12 @@ var depNamespaces []string if depNamespace, found := properties["namespace"]; found { - depNamespaces = []string{depNamespace.(string)} + ns := depNamespace.(string) + if ns == releaseNamespace { + ns = "" + } + + depNamespaces = []string{ns} } var depGroups []string @@ -660,6 +696,25 @@ } } +func parseResourcePolicies(value string) []common.ResourcePolicy { + var policies []common.ResourcePolicy + for _, p := range strings.Split(value, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + policy := common.ResourcePolicy(p) + if policy == common.ResourcePolicyKeep { + policy = common.ResourcePolicySkipDelete + } + + policies = append(policies, policy) + } + + return policies +} + func showLogsOnlyForContainers(meta *spec.ResourceMeta) []string { _, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternShowLogsOnlyForContainers) if !found { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/resource/resource.go new/nelm-1.26.2/pkg/resource/resource.go --- old/nelm-1.25.3/pkg/resource/resource.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/resource/resource.go 2026-07-16 10:38:47.000000000 +0200 @@ -26,10 +26,10 @@ Ownership common.Ownership `json:"ownership"` Recreate bool `json:"recreate"` RecreateOnImmutable bool `json:"recreateOnImmutable"` + ResourcePolicies []common.ResourcePolicy `json:"resourcePolicies"` DefaultReplicasOnCreation *int `json:"defaultReplicasOnCreation,omitempty"` DeleteOnSucceeded bool `json:"deleteOnSucceeded"` DeleteOnFailed bool `json:"deleteOnFailed"` - KeepOnDelete bool `json:"keepOnDelete"` FailMode multitrack.FailMode `json:"failMode"` FailuresAllowed int `json:"failuresAllowed"` IgnoreReadinessProbeFailsForContainers map[string]time.Duration `json:"ignoreReadinessProbeFailsForContainers,omitempty"` @@ -112,7 +112,7 @@ return nil, fmt.Errorf("get external dependencies: %w", err) } - manIntDeps := manualInternalDeployDependencies(res.ResourceMeta) + manIntDeps := manualInternalDeployDependencies(res.ResourceMeta, releaseNamespace) otherUnstructs := lo.Map(otherResSpecs, func(resSpec *spec.ResourceSpec, _ int) *unstructured.Unstructured { return resSpec.Unstruct @@ -130,7 +130,6 @@ FailMode: failMode(res.ResourceMeta), FailuresAllowed: failuresAllowed(res.Unstruct), IgnoreReadinessProbeFailsForContainers: ignoreReadinessProbeFailsForContainers(res.ResourceMeta), - KeepOnDelete: KeepOnDelete(res.ResourceMeta, releaseNamespace), LogRegex: logRegex(res.ResourceMeta), LogRegexesForContainers: logRegexesForContainers(res.ResourceMeta), ManualInternalDependencies: manIntDeps, @@ -138,6 +137,7 @@ Ownership: ownership(res.ResourceMeta, releaseNamespace, res.StoreAs), Recreate: recreate(res.ResourceMeta), RecreateOnImmutable: recreateOnImmutable(res.ResourceMeta), + ResourcePolicies: ResourcePolicies(res.ResourceMeta, releaseNamespace), ShowLogsOnlyForContainers: showLogsOnlyForContainers(res.ResourceMeta), ShowLogsOnlyForNumberOfReplicas: showLogsOnlyForNumberOfReplicas(res.ResourceMeta), ShowServiceMessages: showServiceMessages(res.ResourceMeta), @@ -164,19 +164,19 @@ AutoInternalDependencies []*InternalDependency DeletePropagation metav1.DeletionPropagation - KeepOnDelete bool ManualInternalDependencies []*InternalDependency Ownership common.Ownership + ResourcePolicies []common.ResourcePolicy } // Construct a DeletableResource from a ResourceSpec. Must never contact the cluster, because // this is called even when no cluster access allowed. func NewDeletableResource(resourceSpec *spec.ResourceSpec, otherResourceSpecs []*spec.ResourceSpec, releaseNamespace string, opts DeletableResourceOptions) *DeletableResource { - var keep bool + var policies []common.ResourcePolicy if err := ValidateResourcePolicy(resourceSpec.ResourceMeta); err != nil { - keep = true + policies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} } else { - keep = KeepOnDelete(resourceSpec.ResourceMeta, releaseNamespace) + policies = ResourcePolicies(resourceSpec.ResourceMeta, releaseNamespace) } var owner common.Ownership @@ -206,9 +206,9 @@ ResourceMeta: resourceSpec.ResourceMeta, AutoInternalDependencies: internalDeleteDependencies(resourceSpec.Unstruct, unstructList), DeletePropagation: delPropagation, - KeepOnDelete: keep, ManualInternalDependencies: manIntDeps, Ownership: owner, + ResourcePolicies: policies, } } @@ -281,7 +281,7 @@ return false } - if delRes.KeepOnDelete { + if lo.Contains(delRes.ResourcePolicies, common.ResourcePolicySkipDelete) { return false } @@ -371,3 +371,19 @@ return instResources, delResources, nil } + +func ResolveResourcePolicies(localRes *InstallableResource, liveMeta *spec.ResourceMeta, releaseNamespace string) []common.ResourcePolicy { + if len(localRes.ResourcePolicies) > 0 || liveMeta == nil { + return localRes.ResourcePolicies + } + + // TODO(major): in the next major keep/skip-delete should also be read/respected only from the manifest, not the cluster. + livePolicies := lo.Filter(ResourcePolicies(liveMeta, releaseNamespace), func(p common.ResourcePolicy, _ int) bool { + return p == common.ResourcePolicySkipDelete + }) + if len(livePolicies) == 0 { + return nil + } + + return livePolicies +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/resource/resource_namespace_dependency_ai_test.go new/nelm-1.26.2/pkg/resource/resource_namespace_dependency_ai_test.go --- old/nelm-1.25.3/pkg/resource/resource_namespace_dependency_ai_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/nelm-1.26.2/pkg/resource/resource_namespace_dependency_ai_test.go 2026-07-16 10:38:47.000000000 +0200 @@ -0,0 +1,108 @@ +//go:build ai_tests + +package resource_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/kube/fake" + "github.com/werf/nelm/pkg/resource" + "github.com/werf/nelm/pkg/resource/spec" +) + +func TestAI_ManualDeployDependencyNormalizesReleaseNamespace(t *testing.T) { + const releaseNamespace = "test-namespace" + + t.Run("modern syntax with release namespace normalizes to empty", func(t *testing.T) { + dep := manualDeployDep(t, releaseNamespace, map[string]string{ + "werf.io/deploy-dependency-target": "state=ready,kind=Deployment,group=apps,name=target,namespace=" + releaseNamespace, + }) + + require.Equal(t, common.ResourceStateReady, dep.ResourceState) + require.Equal(t, []string{""}, dep.Namespaces) + }) + + t.Run("modern syntax with foreign namespace kept literal", func(t *testing.T) { + dep := manualDeployDep(t, releaseNamespace, map[string]string{ + "werf.io/deploy-dependency-target": "state=ready,kind=Deployment,group=apps,name=target,namespace=other-ns", + }) + + require.Equal(t, []string{"other-ns"}, dep.Namespaces) + }) + + t.Run("modern syntax with omitted namespace yields empty list", func(t *testing.T) { + dep := manualDeployDep(t, releaseNamespace, map[string]string{ + "werf.io/deploy-dependency-target": "state=ready,kind=Deployment,group=apps,name=target", + }) + + require.Empty(t, dep.Namespaces) + }) + + t.Run("legacy syntax with release namespace normalizes to empty", func(t *testing.T) { + dep := manualDeployDep(t, releaseNamespace, map[string]string{ + "target.dependency.werf.io": "apps/v1:Deployment:" + releaseNamespace + ":target", + }) + + require.Equal(t, common.ResourceStatePresent, dep.ResourceState) + require.Equal(t, []string{""}, dep.Namespaces) + }) + + t.Run("legacy syntax with foreign namespace kept literal", func(t *testing.T) { + dep := manualDeployDep(t, releaseNamespace, map[string]string{ + "target.dependency.werf.io": "apps/v1:Deployment:other-ns:target", + }) + + require.Equal(t, []string{"other-ns"}, dep.Namespaces) + }) + + t.Run("legacy syntax with omitted namespace keeps single empty-string entry", func(t *testing.T) { + dep := manualDeployDep(t, releaseNamespace, map[string]string{ + "target.dependency.werf.io": "apps/v1:Deployment:target", + }) + + require.Equal(t, []string{""}, dep.Namespaces) + }) +} + +func manualDeployDep(t *testing.T, releaseNamespace string, annotations map[string]string) *resource.InternalDependency { + t.Helper() + + resSpec := newDependentConfigMapSpec(releaseNamespace, annotations) + + clientFactory, err := fake.NewClientFactory(context.Background()) + require.NoError(t, err) + + res, err := resource.NewInstallableResource(resSpec, nil, releaseNamespace, clientFactory, resource.InstallableResourceOptions{}) + require.NoError(t, err) + require.Len(t, res.ManualInternalDependencies, 1) + + return res.ManualInternalDependencies[0] +} + +func newDependentConfigMapSpec(releaseNamespace string, annotations map[string]string) *spec.ResourceSpec { + meta := map[string]interface{}{ + "name": "dependent", + "namespace": releaseNamespace, + } + if len(annotations) > 0 { + anns := map[string]interface{}{} + for k, v := range annotations { + anns[k] = v + } + meta["annotations"] = anns + } + + return spec.NewResourceSpec(&unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": meta, + "data": map[string]interface{}{"key": "value"}, + }, + }, releaseNamespace, spec.ResourceSpecOptions{}) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/resource/resource_policy_test.go new/nelm-1.26.2/pkg/resource/resource_policy_test.go --- old/nelm-1.25.3/pkg/resource/resource_policy_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/nelm-1.26.2/pkg/resource/resource_policy_test.go 2026-07-16 10:38:47.000000000 +0200 @@ -0,0 +1,197 @@ +package resource_test + +import ( + "context" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/kube/fake" + "github.com/werf/nelm/pkg/resource" + "github.com/werf/nelm/pkg/resource/spec" +) + +const resourcePolicyTestNamespace = "test-namespace" + +func TestResolveResourcePolicies(t *testing.T) { + tests := []struct { + name string + chart, live map[string]string + nilLive bool + want []common.ResourcePolicy + }{ + { + name: "no policies", + nilLive: true, + }, + { + name: "chart skip-create only", + chart: map[string]string{"werf.io/resource-policy": "skip-create"}, + nilLive: true, + want: []common.ResourcePolicy{common.ResourcePolicySkipCreate}, + }, + { + name: "chart all skips", + chart: map[string]string{"werf.io/resource-policy": "skip-create,skip-update,skip-recreate"}, + nilLive: true, + want: []common.ResourcePolicy{common.ResourcePolicySkipCreate, common.ResourcePolicySkipUpdate, common.ResourcePolicySkipRecreate}, + }, + { + name: "live skip-update dropped when chart absent", + live: map[string]string{"werf.io/resource-policy": "skip-update"}, + }, + { + name: "live install skips dropped when chart absent", + live: map[string]string{"werf.io/resource-policy": "skip-create,skip-update,skip-recreate"}, + }, + { + name: "live skip-delete retained when chart absent", + live: map[string]string{"werf.io/resource-policy": "skip-delete"}, + want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, + }, + { + name: "live werf.io keep retained as skip-delete when chart absent", + live: map[string]string{"werf.io/resource-policy": "keep"}, + want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, + }, + { + name: "live helm.sh keep retained as skip-delete when chart absent", + live: map[string]string{"helm.sh/resource-policy": "keep"}, + want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, + }, + { + name: "live mixed policies filtered to skip-delete when chart absent", + live: map[string]string{"werf.io/resource-policy": "skip-update,skip-delete"}, + want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, + }, + { + name: "chart present takes precedence over live (no merge)", + chart: map[string]string{"werf.io/resource-policy": "skip-update"}, + live: map[string]string{"werf.io/resource-policy": "skip-delete"}, + want: []common.ResourcePolicy{common.ResourcePolicySkipUpdate}, + }, + { + name: "chart helm.sh keep present suppresses live werf.io skips", + chart: map[string]string{"helm.sh/resource-policy": "keep"}, + live: map[string]string{"werf.io/resource-policy": "skip-create"}, + want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var liveMeta *spec.ResourceMeta + if !tt.nilLive { + liveMeta = resourcePolicyMeta(tt.live) + } + + localRes := chartInstallableResource(t, tt.chart) + assert.Equal(t, tt.want, resource.ResolveResourcePolicies(localRes, liveMeta, resourcePolicyTestNamespace)) + }) + } +} + +func TestResourcePoliciesSkipDelete(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + want bool + }{ + {name: "no annotation", annotations: nil, want: false}, + {name: "helm.sh keep", annotations: map[string]string{"helm.sh/resource-policy": "keep"}, want: true}, + {name: "werf.io keep", annotations: map[string]string{"werf.io/resource-policy": "keep"}, want: true}, + {name: "werf.io skip-delete", annotations: map[string]string{"werf.io/resource-policy": "skip-delete"}, want: true}, + {name: "werf.io skip-create only", annotations: map[string]string{"werf.io/resource-policy": "skip-create"}, want: false}, + {name: "werf.io skip-delete among others", annotations: map[string]string{"werf.io/resource-policy": "skip-update,skip-delete"}, want: true}, + {name: "werf.io overrides helm.sh keep", annotations: map[string]string{"helm.sh/resource-policy": "keep", "werf.io/resource-policy": "skip-create"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := lo.Contains(resource.ResourcePolicies(resourcePolicyMeta(tt.annotations), resourcePolicyTestNamespace), common.ResourcePolicySkipDelete) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestValidateResourcePolicy(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + wantErr bool + }{ + {name: "no annotation", annotations: nil}, + {name: "helm.sh keep", annotations: map[string]string{"helm.sh/resource-policy": "keep"}}, + {name: "helm.sh empty is invalid", annotations: map[string]string{"helm.sh/resource-policy": ""}, wantErr: true}, + {name: "helm.sh skip-create is invalid", annotations: map[string]string{"helm.sh/resource-policy": "skip-create"}, wantErr: true}, + {name: "werf.io keep", annotations: map[string]string{"werf.io/resource-policy": "keep"}}, + {name: "werf.io skip-delete", annotations: map[string]string{"werf.io/resource-policy": "skip-delete"}}, + {name: "werf.io all directives", annotations: map[string]string{"werf.io/resource-policy": "skip-create,skip-update,skip-recreate,skip-delete,keep"}}, + {name: "werf.io with spaces", annotations: map[string]string{"werf.io/resource-policy": "skip-create, skip-update"}}, + {name: "werf.io empty is invalid", annotations: map[string]string{"werf.io/resource-policy": ""}, wantErr: true}, + {name: "werf.io empty segment is invalid", annotations: map[string]string{"werf.io/resource-policy": "skip-create,,skip-update"}, wantErr: true}, + {name: "werf.io unknown value is invalid", annotations: map[string]string{"werf.io/resource-policy": "skip-create,bogus"}, wantErr: true}, + // werf.io fully overrides helm.sh: a bad legacy value must not reject the resource. + {name: "werf.io present ignores bad helm.sh value", annotations: map[string]string{"helm.sh/resource-policy": "bogus", "werf.io/resource-policy": "skip-update"}}, + {name: "werf.io invalid still rejected even with valid helm.sh", annotations: map[string]string{"helm.sh/resource-policy": "keep", "werf.io/resource-policy": "bogus"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := resource.ValidateResourcePolicy(resourcePolicyMeta(tt.annotations)) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func chartInstallableResource(t *testing.T, annotations map[string]string) *resource.InstallableResource { + t.Helper() + + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-configmap", + }, + }, + } + + resSpec := spec.NewResourceSpec(obj, resourcePolicyTestNamespace, spec.ResourceSpecOptions{}) + if len(annotations) > 0 { + resSpec.SetAnnotations(annotations) + } + + clientFactory, err := fake.NewClientFactory(context.Background()) + require.NoError(t, err) + + localRes, err := resource.NewInstallableResource(resSpec, nil, resourcePolicyTestNamespace, clientFactory, resource.InstallableResourceOptions{}) + require.NoError(t, err) + + return localRes +} + +func resourcePolicyMeta(annotations map[string]string) *spec.ResourceMeta { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-configmap", + }, + }, + } + if len(annotations) > 0 { + obj.SetAnnotations(annotations) + } + + return spec.NewResourceMetaFromUnstructured(obj, resourcePolicyTestNamespace, "") +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/pkg/resource/resource_test.go new/nelm-1.26.2/pkg/resource/resource_test.go --- old/nelm-1.25.3/pkg/resource/resource_test.go 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/pkg/resource/resource_test.go 2026-07-16 10:38:47.000000000 +0200 @@ -1172,7 +1172,7 @@ { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) - res.KeepOnDelete = true + res.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} return res }, @@ -1186,6 +1186,80 @@ }, name: `for resource with helm.sh/resource-policy="keep"`, }, + { + expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { + res := defaultInstallableResource(resSpec) + res.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} + + return res + }, + input: func() *spec.ResourceSpec { + resSpec := defaultResourceSpec(s.releaseNamespace) + resSpec.SetAnnotations(lo.Assign(resSpec.Annotations, map[string]string{ + "werf.io/resource-policy": "keep", + })) + + return resSpec + }, + name: `for resource with werf.io/resource-policy="keep"`, + }, + { + expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { + res := defaultInstallableResource(resSpec) + res.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} + + return res + }, + input: func() *spec.ResourceSpec { + resSpec := defaultResourceSpec(s.releaseNamespace) + resSpec.SetAnnotations(lo.Assign(resSpec.Annotations, map[string]string{ + "werf.io/resource-policy": "skip-delete", + })) + + return resSpec + }, + name: `for resource with werf.io/resource-policy="skip-delete"`, + }, + { + expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { + res := defaultInstallableResource(resSpec) + res.ResourcePolicies = []common.ResourcePolicy{ + common.ResourcePolicySkipCreate, + common.ResourcePolicySkipUpdate, + common.ResourcePolicySkipRecreate, + common.ResourcePolicySkipDelete, + } + + return res + }, + input: func() *spec.ResourceSpec { + resSpec := defaultResourceSpec(s.releaseNamespace) + resSpec.SetAnnotations(lo.Assign(resSpec.Annotations, map[string]string{ + "werf.io/resource-policy": "skip-create, skip-update, skip-recreate, skip-delete", + })) + + return resSpec + }, + name: `for resource with werf.io/resource-policy listing all skip directives`, + }, + { + expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { + res := defaultInstallableResource(resSpec) + res.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipUpdate} + + return res + }, + input: func() *spec.ResourceSpec { + resSpec := defaultResourceSpec(s.releaseNamespace) + resSpec.SetAnnotations(lo.Assign(resSpec.Annotations, map[string]string{ + "helm.sh/resource-policy": "keep", + "werf.io/resource-policy": "skip-update", + })) + + return resSpec + }, + name: `for resource where werf.io/resource-policy overrides helm.sh/resource-policy`, + }, } for _, tc := range testCases { @@ -1554,7 +1628,7 @@ func defaultReleaseNamespaceDeletableResource(resSpec *spec.ResourceSpec) *resource.DeletableResource { res := defaultDeletableResource(resSpec.ResourceMeta) res.Ownership = common.OwnershipAnyone - res.KeepOnDelete = true + res.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} return res } @@ -1562,7 +1636,7 @@ func defaultReleaseNamespaceInstallableResource(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) res.Ownership = common.OwnershipAnyone - res.KeepOnDelete = true + res.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} res.DeployConditions = map[common.On][]common.Stage{ common.InstallOnInstall: {common.StagePrePreInstall}, common.InstallOnUpgrade: {common.StagePrePreInstall}, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/nelm-1.25.3/trdl_channels.yaml new/nelm-1.26.2/trdl_channels.yaml --- old/nelm-1.25.3/trdl_channels.yaml 2026-06-24 21:01:58.000000000 +0200 +++ new/nelm-1.26.2/trdl_channels.yaml 2026-07-16 10:38:47.000000000 +0200 @@ -2,12 +2,12 @@ - name: "1" channels: - name: alpha - version: 1.25.2 + version: 1.26.0 - name: beta - version: 1.25.0 + version: 1.25.3 - name: ea - version: 1.24.3 + version: 1.25.0 - name: stable - version: 1.24.1 + version: 1.24.3 - name: rock-solid - version: 1.24.0 + version: 1.24.1 ++++++ nelm.obsinfo ++++++ --- /var/tmp/diff_new_pack.YknDgy/_old 2026-09-21 12:08:03.998290952 +0200 +++ /var/tmp/diff_new_pack.YknDgy/_new 2026-09-21 12:08:04.003291161 +0200 @@ -1,5 +1,5 @@ name: nelm -version: 1.25.3 -mtime: 1782327718 -commit: 5d3d03cb0b97bdb6df7a5b22320bfbf53edafdb4 +version: 1.26.2 +mtime: 1784191127 +commit: 6505774f6217ec78612d8ca6ccad1e5b3e2cea7f ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/nelm/vendor.tar.gz /work/SRC/openSUSE:Factory/.nelm.new.383539/vendor.tar.gz differ: char 130, line 2
