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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions integration_tests/apply.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Test.k8s("verify resource was created in fake environment", function(t)
metadata = {
name = "my-app",
namespace = "apply-team",
managedFields = Ignore(),
},
spec = {
image = "example.com/my-app:v1",
Expand Down Expand Up @@ -118,6 +119,7 @@ Test.k8s("verify resource was updated in fake environment", function(t)
metadata = {
name = "my-app",
namespace = "apply-team",
managedFields = Ignore(),
},
spec = {
image = "example.com/my-app:v2",
Expand Down
139 changes: 139 additions & 0 deletions integration_tests/apply_replace.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
local user = User.new()
local team = Team.new("apply-replace-team", "Apply field removal testing", "#apply-replace-team")
team:addMember(user)

Test.rest("create resources with fields that will be omitted", function(t)
t.addHeader("x-user-email", user:email())
t.send("POST", "/api/v1/teams/apply-replace-team/environments/dev/apply", [[
{"resources": [
{
"apiVersion": "nais.io/v1alpha1",
"kind": "Application",
"metadata": {
"name": "my-app",
"labels": {"kept": "same", "removed": "old"},
"annotations": {"removed": "old"}
},
"spec": {
"image": "example.com/my-app:v1",
"resources": {
"limits": {"cpu": "1", "memory": "512Mi"},
"requests": {"cpu": "100m", "memory": "256Mi"}
}
}
},
{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "my-config"},
"data": {"kept": "old", "removed": "old"},
"binaryData": {"removed": "b2xk"}
}
]}
]])
t.check(200, {
results = {
{ resource = "Application/my-app", environmentName = "dev", status = "created" },
{ resource = "ConfigMap/my-config", environmentName = "dev", status = "created" },
},
})
end)

local replacement = [[
{"resources": [
{
"apiVersion": "nais.io/v1alpha1",
"kind": "Application",
"metadata": {
"name": "my-app",
"labels": {"kept": "same"}
},
"spec": {
"image": "example.com/my-app:v1",
"resources": {
"limits": {"memory": "512Mi"},
"requests": {"cpu": "100m", "memory": "256Mi"}
}
}
},
{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "my-config"},
"data": {"kept": "new"}
}
]}
]]

Test.rest("apply removes omitted owned fields and reports changes", function(t)
t.addHeader("x-user-email", user:email())
t.send("POST", "/api/v1/teams/apply-replace-team/environments/dev/apply", replacement)
t.check(200, {
results = {
{
resource = "Application/my-app",
environmentName = "dev",
status = "applied",
changedFields = {
{ field = "metadata.annotations.removed", oldValue = "old" },
{ field = "metadata.labels.removed", oldValue = "old" },
{ field = "spec.resources.limits.cpu", oldValue = "1" },
},
},
{
resource = "ConfigMap/my-config",
environmentName = "dev",
status = "applied",
changedFields = {
{ field = "binaryData.removed", oldValue = "b2xk" },
{ field = "data.kept", oldValue = "old", newValue = "new" },
{ field = "data.removed", oldValue = "old" },
},
},
},
})
end)

Test.k8s("omitted CPU limit and metadata are removed from the stored Application", function(t)
t.check("nais.io/v1alpha1", "applications", "dev", team:slug(), "my-app", {
apiVersion = "nais.io/v1alpha1",
kind = "Application",
metadata = {
name = "my-app",
namespace = team:slug(),
managedFields = Ignore(),
labels = { kept = "same" },
},
spec = {
image = "example.com/my-app:v1",
resources = {
limits = { memory = "512Mi" },
requests = { cpu = "100m", memory = "256Mi" },
},
},
})
end)

Test.k8s("omitted data keys and top-level fields are removed from the stored ConfigMap", function(t)
t.check("v1", "configmaps", "dev", team:slug(), "my-config", {
apiVersion = "v1",
kind = "ConfigMap",
metadata = {
name = "my-config",
namespace = team:slug(),
managedFields = Ignore(),
},
data = { kept = "new" },
})
end)

Test.rest("reapplying the replacement has no changes", function(t)
t.addHeader("x-user-email", user:email())
t.send("POST", "/api/v1/teams/apply-replace-team/environments/dev/apply", replacement)
t.check(200, {
results = {
{ resource = "Application/my-app", environmentName = "dev", status = "applied" },
{ resource = "ConfigMap/my-config", environmentName = "dev", status = "applied" },
},
})
end)
1 change: 1 addition & 0 deletions integration_tests/apply_secret.lua
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Test.k8s("Secret apply still updates the Kubernetes object", function(t)
metadata = {
name = "apply-secret",
namespace = team:slug(),
managedFields = Ignore(),
annotations = { snapshot = "NEW_SNAPSHOT" },
},
})
Expand Down
96 changes: 76 additions & 20 deletions internal/apply/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/util/csaupgrade"
)

const fieldManager = "nais-api"

// ApplyResult holds the before and after states of a server-side apply operation.
// ApplyResult holds the before and after states of an apply operation.
type ApplyResult struct {
// Before is the state of the object before the apply. Nil if the object did not exist.
Before *unstructured.Unstructured
Expand All @@ -25,9 +27,9 @@ type ApplyResult struct {
Created bool
}

// ApplyResource performs a Kubernetes server-side apply for a single resource.
// It fetches the current state (before), applies the resource, and returns both
// before and after states so the caller can diff them.
// ApplyResource uses server-side apply after migrating ownership from legacy deployers.
// Other field managers retain their ownership, and conflicting changes are rejected.
// It returns both before and after states so the caller can diff them.
func ApplyResource(
ctx context.Context,
client dynamic.Interface,
Expand All @@ -46,7 +48,6 @@ func ApplyResource(

resourceClient := client.Resource(gvr).Namespace(namespace)

// Step 1: Get the current state of the object (before-state).
before, err := resourceClient.Get(ctx, name, metav1.GetOptions{})
if err != nil {
if !apierrors.IsNotFound(err) {
Expand All @@ -55,24 +56,56 @@ func ApplyResource(
before = nil
}

// Step 2: Marshal the object to JSON for the apply patch.
data, err := json.Marshal(obj.Object)
submittedVersion := obj.GetResourceVersion()
if submittedVersion != "" {
if before == nil {
return nil, apierrors.NewConflict(gvr.GroupResource(), name, fmt.Errorf("resourceVersion %q refers to a resource that no longer exists", submittedVersion))
}
if submittedVersion != before.GetResourceVersion() {
return nil, apierrors.NewConflict(gvr.GroupResource(), name, fmt.Errorf("requested resourceVersion %q does not match current version %q", submittedVersion, before.GetResourceVersion()))
}
}
desired := obj.DeepCopy()
if before != nil {
legacyManagers := sets.New("kubectl-create", "kubectl-client-side-apply", "deployd")
if err := validateMigrationVersions(before.GetManagedFields(), legacyManagers); err != nil {
return nil, fmt.Errorf("preparing ownership migration for %s/%s: %w", namespace, name, err)
}
// Transfer legacy Update ownership to SSA so omitted fields can be removed
// without taking ownership from other managers.
patch, err := csaupgrade.UpgradeManagedFieldsPatch(
before,
legacyManagers,
fieldManager,
)
if err != nil {
return nil, fmt.Errorf("preparing ownership migration for %s/%s: %w", namespace, name, err)
}
if patch != nil {
migrated, err := resourceClient.Patch(ctx, name, types.JSONPatchType, patch, metav1.PatchOptions{
FieldManager: fieldManager,
})
if err != nil {
return nil, fmt.Errorf("migrating field ownership for %s/%s: %w", namespace, name, err)
Comment on lines +84 to +89
}
if submittedVersion != "" {
if migrated == nil || migrated.GetResourceVersion() == "" {
return nil, fmt.Errorf("ownership migration for %s/%s returned no resourceVersion", namespace, name)
}
desired.SetResourceVersion(migrated.GetResourceVersion())
}
Comment on lines +91 to +96
}
}

data, err := json.Marshal(desired.Object)
if err != nil {
return nil, fmt.Errorf("marshaling resource to JSON: %w", err)
}

// Step 3: Server-side apply using PATCH with ApplyPatchType.
after, err := resourceClient.Patch(
ctx,
name,
types.ApplyPatchType,
data,
metav1.PatchOptions{
FieldManager: fieldManager,
Force: new(true),
FieldValidation: metav1.FieldValidationStrict,
},
)
after, err := resourceClient.Patch(ctx, name, types.ApplyPatchType, data, metav1.PatchOptions{
FieldManager: fieldManager,
Force: new(false),
FieldValidation: metav1.FieldValidationStrict,
})
Comment on lines +104 to +108
if err != nil {
return nil, fmt.Errorf("applying %s/%s: %w", namespace, name, err)
}
Expand All @@ -83,3 +116,26 @@ func ApplyResource(
Created: before == nil,
}, nil
}

func validateMigrationVersions(entries []metav1.ManagedFieldsEntry, legacyManagers sets.Set[string]) error {
var targetVersion string
for _, entry := range entries {
if entry.Manager == fieldManager && entry.Operation == metav1.ManagedFieldsOperationApply && entry.Subresource == "" {
targetVersion = entry.APIVersion
break
}
}
// csaupgrade drops legacy entries in other API versions without merging them.
for _, entry := range entries {
if !legacyManagers.Has(entry.Manager) || entry.Operation != metav1.ManagedFieldsOperationUpdate || entry.Subresource != "" {
continue
}
if targetVersion == "" {
targetVersion = entry.APIVersion
}
if entry.APIVersion != targetVersion {
return fmt.Errorf("cannot safely migrate %q ownership from API version %q into %q ownership for API version %q", entry.Manager, entry.APIVersion, fieldManager, targetVersion)
}
}
return nil
}
Loading
Loading