Skip to content

Commit 0eb018b

Browse files
committed
feat: limit resource deletion on trigger
Currently if `tilt trigger` is run on a resource with RBACs, admission webhooks or ServiceAccounts those are removed causing cascading failures on other resources with `resource_deps` to them. This commit changes the `tilt trigger` logic to not call ForceDelete on this kind of resources while keeping for example `Job`s and `Pod`s in the scope of ForceDelete for faster development loops. Signed-off-by: Jaakko Sirén <jaakko@craci.com>
1 parent 0729c7d commit 0eb018b

4 files changed

Lines changed: 190 additions & 16 deletions

File tree

internal/controllers/core/kubernetesapply/reconciler.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"fmt"
77
"sync"
8+
"time"
89

910
"github.com/pkg/errors"
1011
batchv1 "k8s.io/api/batch/v1"
@@ -851,6 +852,91 @@ func (r *Reconciler) ForceDelete(ctx context.Context, nn types.NamespacedName,
851852
return nil
852853
}
853854

855+
// triggerRestartSet is the set of workload-controller GVKs whose pod
856+
// template we stamp with kubectl.kubernetes.io/restartedAt on trigger
857+
// to force a rolling restart — the same mechanism `kubectl rollout
858+
// restart` uses.
859+
var triggerRestartSet = map[schema.GroupKind]bool{
860+
{Group: "apps", Kind: "Deployment"}: true,
861+
{Group: "apps", Kind: "StatefulSet"}: true,
862+
{Group: "apps", Kind: "DaemonSet"}: true,
863+
}
864+
865+
// triggerRecreateSet is the set of GVKs whose spec is immutable or
866+
// whose "run" IS the object (Jobs, standalone Pods). Trigger on a
867+
// resource containing these force-deletes the specific entity; the
868+
// apply pass then recreates it.
869+
var triggerRecreateSet = map[schema.GroupKind]bool{
870+
{Group: "batch", Kind: "Job"}: true,
871+
{Group: "", Kind: "Pod"}: true,
872+
}
873+
874+
// PrepareTriggerEntities partitions the rendered entities of a
875+
// KubernetesApply for a `tilt trigger` / force-refresh apply pass.
876+
// Workload controllers receive a kubectl.kubernetes.io/restartedAt
877+
// stamp on their pod template so the apply triggers a rolling
878+
// restart; Jobs and standalone Pods are returned in toRecreate for
879+
// per-entity force-delete before apply; everything else passes
880+
// through unchanged. Ordering is preserved.
881+
func PrepareTriggerEntities(entities []k8s.K8sEntity, now time.Time) (toApply []k8s.K8sEntity, toRecreate []k8s.K8sEntity, err error) {
882+
stamp := now.UTC().Format(time.RFC3339)
883+
toApply = make([]k8s.K8sEntity, 0, len(entities))
884+
for _, e := range entities {
885+
gk := e.GVK().GroupKind()
886+
switch {
887+
case triggerRecreateSet[gk]:
888+
toRecreate = append(toRecreate, e)
889+
toApply = append(toApply, e)
890+
case triggerRestartSet[gk]:
891+
stamped, rerr := withPodTemplateRestartedAt(e, stamp)
892+
if rerr != nil {
893+
return nil, nil, fmt.Errorf("stamping restartedAt on %s/%s: %v",
894+
e.GVK().Kind, e.Name(), rerr)
895+
}
896+
toApply = append(toApply, stamped)
897+
default:
898+
toApply = append(toApply, e)
899+
}
900+
}
901+
return toApply, toRecreate, nil
902+
}
903+
904+
func withPodTemplateRestartedAt(e k8s.K8sEntity, stamp string) (k8s.K8sEntity, error) {
905+
const annKey = "kubectl.kubernetes.io/restartedAt"
906+
out := e.DeepCopy()
907+
templates, err := k8s.ExtractPodTemplateSpec(out)
908+
if err != nil {
909+
return k8s.K8sEntity{}, err
910+
}
911+
if len(templates) == 0 {
912+
return k8s.K8sEntity{}, fmt.Errorf("no pod template on %s/%s", e.GVK().Kind, e.Name())
913+
}
914+
for _, t := range templates {
915+
if t.Annotations == nil {
916+
t.Annotations = map[string]string{}
917+
}
918+
t.Annotations[annKey] = stamp
919+
}
920+
return out, nil
921+
}
922+
923+
// ForceDeleteEntities deletes a pre-filtered list of entities in
924+
// reverse dependency order. Used by the trigger path to recreate Jobs
925+
// and standalone Pods after PrepareTriggerEntities has partitioned the
926+
// rendered set.
927+
func (r *Reconciler) ForceDeleteEntities(ctx context.Context, nn types.NamespacedName,
928+
cluster *v1alpha1.Cluster, entities []k8s.K8sEntity, reason string) error {
929+
if len(entities) == 0 {
930+
return nil
931+
}
932+
r.recordDelete(nn)
933+
r.bestEffortDelete(ctx, nn, deleteSpec{
934+
cluster: cluster,
935+
entities: k8s.ReverseSortedEntities(entities),
936+
}, reason)
937+
return nil
938+
}
939+
854940
// Update the status if necessary.
855941
func (r *Reconciler) maybeUpdateStatus(ctx context.Context, nn types.NamespacedName, obj *v1alpha1.KubernetesApply) (*v1alpha1.KubernetesApply, error) {
856942
newStatus := v1alpha1.KubernetesApplyStatus{}

internal/controllers/core/kubernetesapply/reconciler_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,53 @@ func TestForceDeleteWithCmd(t *testing.T) {
689689
}
690690
}
691691

692+
func TestPrepareTriggerEntitiesStampsWorkloadController(t *testing.T) {
693+
entities, err := k8s.ParseYAMLFromString(testyaml.SanchoYAML)
694+
require.NoError(t, err)
695+
696+
stamp := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
697+
toApply, toRecreate, err := PrepareTriggerEntities(entities, stamp)
698+
require.NoError(t, err)
699+
require.Empty(t, toRecreate)
700+
require.Len(t, toApply, 1)
701+
702+
out, err := k8s.SerializeSpecYAML(toApply)
703+
require.NoError(t, err)
704+
assert.Contains(t, out, "kubectl.kubernetes.io/restartedAt: \"2024-01-02T03:04:05Z\"")
705+
}
706+
707+
func TestPrepareTriggerEntitiesRecreatesJob(t *testing.T) {
708+
entities, err := k8s.ParseYAMLFromString(testyaml.JobYAML)
709+
require.NoError(t, err)
710+
711+
toApply, toRecreate, err := PrepareTriggerEntities(entities, time.Now())
712+
require.NoError(t, err)
713+
require.Len(t, toApply, 1)
714+
require.Len(t, toRecreate, 1)
715+
assert.Equal(t, "Job", toRecreate[0].GVK().Kind)
716+
}
717+
718+
func TestPrepareTriggerEntitiesPassesThroughConfigMap(t *testing.T) {
719+
yaml := `apiVersion: v1
720+
kind: ConfigMap
721+
metadata:
722+
name: untouched
723+
data:
724+
hello: world
725+
`
726+
entities, err := k8s.ParseYAMLFromString(yaml)
727+
require.NoError(t, err)
728+
729+
toApply, toRecreate, err := PrepareTriggerEntities(entities, time.Now())
730+
require.NoError(t, err)
731+
require.Empty(t, toRecreate)
732+
require.Len(t, toApply, 1)
733+
734+
out, err := k8s.SerializeSpecYAML(toApply)
735+
require.NoError(t, err)
736+
assert.NotContains(t, out, "restartedAt")
737+
}
738+
692739
func TestDisableByConfigmap(t *testing.T) {
693740
f := newFixture(t)
694741
ka := v1alpha1.KubernetesApply{

internal/engine/buildcontrol/image_build_and_deployer.go

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/tilt-dev/tilt/internal/controllers/core/cmdimage"
1414
"github.com/tilt-dev/tilt/internal/controllers/core/dockerimage"
1515
"github.com/tilt-dev/tilt/internal/controllers/core/kubernetesapply"
16+
"github.com/tilt-dev/tilt/internal/k8s"
1617
"github.com/tilt-dev/tilt/internal/store"
1718
"github.com/tilt-dev/tilt/internal/store/k8sconv"
1819
"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
@@ -81,17 +82,17 @@ func (ibd *ImageBuildAndDeployer) BuildAndDeploy(ctx context.Context, st store.R
8182
numStages++
8283
}
8384

84-
hasDeleteStep := stateSet.FullBuildTriggered()
85-
if hasDeleteStep {
85+
hasForceUpdateStep := stateSet.FullBuildTriggered()
86+
if hasForceUpdateStep {
8687
numStages++
8788
}
8889

8990
ps := build.NewPipelineState(ctx, numStages, ibd.clock)
9091
defer func() { ps.End(ctx, err) }()
9192

92-
if hasDeleteStep {
93+
if hasForceUpdateStep {
9394
ps.StartPipelineStep(ctx, "Force update")
94-
err = ibd.delete(ps.AttachLogger(ctx), kTarget, kCluster)
95+
kTarget, err = ibd.prepareForceUpdate(ps.AttachLogger(ctx), kTarget, kCluster)
9596
if err != nil {
9697
return store.BuildResultSet{}, WrapDontFallBackError(err)
9798
}
@@ -197,9 +198,42 @@ func (ibd *ImageBuildAndDeployer) deploy(
197198
return store.NewK8sDeployResult(kTargetID, filter), nil
198199
}
199200

200-
// Delete all the resources in the Kubernetes target, to ensure that they restart when
201-
// we re-apply them.
202-
func (ibd *ImageBuildAndDeployer) delete(ctx context.Context, k8sTarget model.K8sTarget, cluster *v1alpha1.Cluster) error {
203-
kTargetNN := types.NamespacedName{Name: k8sTarget.ID().Name.String()}
204-
return ibd.r.ForceDelete(ctx, kTargetNN, k8sTarget.KubernetesApplySpec, cluster, "force update")
201+
// prepareForceUpdate rewrites the target's rendered YAML for a
202+
// `tilt trigger` / force-refresh apply pass. Workload controllers get
203+
// a kubectl.kubernetes.io/restartedAt stamp so the apply triggers a
204+
// rolling restart; Jobs and standalone Pods are force-deleted here and
205+
// recreated by the apply; everything else passes through so the apply
206+
// pipeline updates it in place. Specs with a custom DeleteCmd keep the
207+
// legacy blanket-delete path via Reconciler.ForceDelete.
208+
func (ibd *ImageBuildAndDeployer) prepareForceUpdate(ctx context.Context, kTarget model.K8sTarget, cluster *v1alpha1.Cluster) (model.K8sTarget, error) {
209+
spec := kTarget.KubernetesApplySpec
210+
kTargetNN := types.NamespacedName{Name: kTarget.ID().Name.String()}
211+
212+
if spec.YAML == "" {
213+
if spec.DeleteCmd != nil {
214+
return kTarget, ibd.r.ForceDelete(ctx, kTargetNN, spec, cluster, "force update")
215+
}
216+
return kTarget, nil
217+
}
218+
219+
entities, err := k8s.ParseYAMLFromString(spec.YAML)
220+
if err != nil {
221+
return kTarget, fmt.Errorf("force update: parsing YAML: %v", err)
222+
}
223+
224+
toApply, toRecreate, err := kubernetesapply.PrepareTriggerEntities(entities, ibd.clock.Now())
225+
if err != nil {
226+
return kTarget, fmt.Errorf("force update: preparing entities: %v", err)
227+
}
228+
229+
if err := ibd.r.ForceDeleteEntities(ctx, kTargetNN, cluster, toRecreate, "force update (recreate)"); err != nil {
230+
return kTarget, fmt.Errorf("force update: deleting ephemeral entities: %v", err)
231+
}
232+
233+
rewritten, err := k8s.SerializeSpecYAML(toApply)
234+
if err != nil {
235+
return kTarget, fmt.Errorf("force update: serializing rewritten YAML: %v", err)
236+
}
237+
kTarget.KubernetesApplySpec.YAML = rewritten
238+
return kTarget, nil
205239
}

internal/engine/buildcontrol/image_build_and_deployer_test.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ func TestForceUpdateK8s(t *testing.T) {
7474
_, err := f.BuildAndDeploy(BuildTargets(m), stateSet)
7575
require.NoError(t, err)
7676

77-
// A force rebuild should delete the old resources.
78-
assert.Equal(t, 1, strings.Count(f.k8s.DeletedYaml, "Deployment"))
77+
// A force update stamps a restartedAt annotation on the pod template
78+
// rather than deleting the Deployment; the Deployment controller then
79+
// rolls its pods on the next apply.
80+
assert.Equal(t, 0, strings.Count(f.k8s.DeletedYaml, "Deployment"))
81+
// Fake clock is fixed at 2019-01-01 01:01:01 UTC; the stamp is RFC3339.
82+
assert.Contains(t, f.k8s.Yaml,
83+
"kubectl.kubernetes.io/restartedAt: \"2019-01-01T01:01:01Z\"")
7984
}
8085

8186
func TestForceUpdateDoesNotDeleteNamespace(t *testing.T) {
@@ -105,17 +110,19 @@ metadata:
105110
_, err := f.BuildAndDeploy(BuildTargets(m), stateSet)
106111
require.NoError(t, err)
107112

108-
// A force rebuild should delete the ConfigMap but not the Namespace.
109-
assert.Equal(t, 1, strings.Count(f.k8s.DeletedYaml, "kind: ConfigMap"))
113+
// Force update no longer deletes non-workload kinds: the ConfigMap
114+
// updates in place via the apply pipeline and the Namespace was
115+
// already preserved by ForceDelete's filter.
116+
assert.Equal(t, 0, strings.Count(f.k8s.DeletedYaml, "kind: ConfigMap"))
110117
assert.Equal(t, 0, strings.Count(f.k8s.DeletedYaml, "kind: Namespace"))
111118
}
112119

113-
func TestDeleteShouldHappenInReverseOrder(t *testing.T) {
120+
func TestForceDeleteHappensInReverseOrder(t *testing.T) {
114121
f := newIBDFixture(t, clusterid.ProductGKE)
115122

116123
m := newK8sMultiEntityManifest("sancho")
117-
118-
err := f.ibd.delete(f.ctx, m.K8sTarget(), nil)
124+
nn := ktypes.NamespacedName{Name: m.K8sTarget().ID().Name.String()}
125+
err := f.ibd.r.ForceDelete(f.ctx, nn, m.K8sTarget().KubernetesApplySpec, nil, "test")
119126
require.NoError(t, err)
120127

121128
assert.Regexp(t, "(?s)name: sancho-deployment.*name: sancho-pvc", f.k8s.DeletedYaml) // pvc comes after deployment

0 commit comments

Comments
 (0)