From 8f2cd0a37900cf759a463fa2e20c2e3514a37069 Mon Sep 17 00:00:00 2001 From: William Sena Date: Wed, 2 Sep 2026 09:25:27 -0300 Subject: [PATCH 01/10] feat: add idle-candidate detection helpers for Knative cleanup Co-Authored-By: Claude Sonnet 5 --- .../idle_knative_cleanup_controller.go | 64 +++++++++++ .../idle_knative_cleanup_helpers_test.go | 107 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 controllers/idle_knative_cleanup_controller.go create mode 100644 controllers/idle_knative_cleanup_helpers_test.go diff --git a/controllers/idle_knative_cleanup_controller.go b/controllers/idle_knative_cleanup_controller.go new file mode 100644 index 0000000..0f285ad --- /dev/null +++ b/controllers/idle_knative_cleanup_controller.go @@ -0,0 +1,64 @@ +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "fmt" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + idleAnnotationExclude = "cleaner.vtex.io/exclude" + idleAnnotationIdleSince = "cleaner.vtex.io/idle-since" + knativeMinScaleAnnotation = "autoscaling.knative.dev/min-scale" + knativeServiceLabel = "serving.knative.dev/service" +) + +// isExcluded reports whether the Service opted out of idle cleanup via the +// cleaner.vtex.io/exclude annotation. +func isExcluded(svc *unstructured.Unstructured) bool { + return svc.GetAnnotations()[idleAnnotationExclude] == "true" +} + +// hasMinScaleZero reports whether the Service's revision template declares +// autoscaling.knative.dev/min-scale: "0". +func hasMinScaleZero(svc *unstructured.Unstructured) (bool, error) { + anns, found, err := unstructured.NestedStringMap(svc.Object, "spec", "template", "metadata", "annotations") + if err != nil { + return false, fmt.Errorf("reading spec.template.metadata.annotations: %w", err) + } + if !found { + return false, nil + } + return anns[knativeMinScaleAnnotation] == "0", nil +} + +// readIdleSince reads and parses the cleaner.vtex.io/idle-since annotation. +// ok is false when the annotation is absent. +func readIdleSince(svc *unstructured.Unstructured) (time.Time, bool, error) { + val, ok := svc.GetAnnotations()[idleAnnotationIdleSince] + if !ok { + return time.Time{}, false, nil + } + t, err := time.Parse(time.RFC3339, val) + if err != nil { + return time.Time{}, false, fmt.Errorf("parsing %s annotation %q: %w", idleAnnotationIdleSince, val, err) + } + return t, true, nil +} diff --git a/controllers/idle_knative_cleanup_helpers_test.go b/controllers/idle_knative_cleanup_helpers_test.go new file mode 100644 index 0000000..55c8c0d --- /dev/null +++ b/controllers/idle_knative_cleanup_helpers_test.go @@ -0,0 +1,107 @@ +package controllers + +import ( + "testing" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newTestService() *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]interface{}{}} +} + +func TestIsExcluded(t *testing.T) { + cases := []struct { + name string + annotations map[string]string + want bool + }{ + {name: "no annotations", annotations: nil, want: false}, + {name: "excluded true", annotations: map[string]string{idleAnnotationExclude: "true"}, want: true}, + {name: "excluded false string", annotations: map[string]string{idleAnnotationExclude: "false"}, want: false}, + {name: "unrelated annotation", annotations: map[string]string{"foo": "bar"}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newTestService() + if tc.annotations != nil { + svc.SetAnnotations(tc.annotations) + } + if got := isExcluded(svc); got != tc.want { + t.Errorf("isExcluded() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestHasMinScaleZero(t *testing.T) { + cases := []struct { + name string + templateAnns map[string]string + skipSet bool + want bool + }{ + {name: "min-scale zero", templateAnns: map[string]string{knativeMinScaleAnnotation: "0"}, want: true}, + {name: "min-scale one", templateAnns: map[string]string{knativeMinScaleAnnotation: "1"}, want: false}, + {name: "no min-scale annotation", templateAnns: map[string]string{"other": "x"}, want: false}, + {name: "no template annotations at all", skipSet: true, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newTestService() + if !tc.skipSet { + err := unstructured.SetNestedStringMap(svc.Object, tc.templateAnns, "spec", "template", "metadata", "annotations") + if err != nil { + t.Fatalf("SetNestedStringMap() error = %v", err) + } + } + got, err := hasMinScaleZero(svc) + if err != nil { + t.Fatalf("hasMinScaleZero() error = %v", err) + } + if got != tc.want { + t.Errorf("hasMinScaleZero() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestReadIdleSince(t *testing.T) { + fixed := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + + t.Run("annotation absent", func(t *testing.T) { + svc := newTestService() + _, ok, err := readIdleSince(svc) + if err != nil { + t.Fatalf("readIdleSince() error = %v", err) + } + if ok { + t.Errorf("readIdleSince() ok = true, want false") + } + }) + + t.Run("valid RFC3339 annotation", func(t *testing.T) { + svc := newTestService() + svc.SetAnnotations(map[string]string{idleAnnotationIdleSince: fixed.Format(time.RFC3339)}) + got, ok, err := readIdleSince(svc) + if err != nil { + t.Fatalf("readIdleSince() error = %v", err) + } + if !ok { + t.Fatalf("readIdleSince() ok = false, want true") + } + if !got.Equal(fixed) { + t.Errorf("readIdleSince() = %v, want %v", got, fixed) + } + }) + + t.Run("malformed annotation returns error", func(t *testing.T) { + svc := newTestService() + svc.SetAnnotations(map[string]string{idleAnnotationIdleSince: "not-a-timestamp"}) + _, _, err := readIdleSince(svc) + if err == nil { + t.Fatalf("readIdleSince() error = nil, want error for malformed timestamp") + } + }) +} From e0b04e3f01167bf1dd148386f229e7bab21917c0 Mon Sep 17 00:00:00 2001 From: William Sena Date: Wed, 2 Sep 2026 09:27:50 -0300 Subject: [PATCH 02/10] feat: reconcile Knative Services to mark idle-since when scaled to zero Co-Authored-By: Claude Sonnet 5 --- config/rbac/role.yaml | 19 ++- .../idle_knative_cleanup_controller.go | 141 ++++++++++++++++++ .../idle_knative_cleanup_controller_test.go | 95 ++++++++++++ controllers/suite_test.go | 12 +- .../serving.knative.dev_services.yaml | 22 +++ 5 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 controllers/idle_knative_cleanup_controller_test.go create mode 100644 controllers/testdata/serving.knative.dev_services.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 59aef5d..078c32e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -2,7 +2,6 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - creationTimestamp: null name: manager-role rules: - apiGroups: @@ -12,6 +11,14 @@ rules: verbs: - create - patch +- apiGroups: + - apps + resources: + - deployments + verbs: + - get + - list + - watch - apiGroups: - cleaner.vtex.io resources: @@ -38,3 +45,13 @@ rules: - get - patch - update +- apiGroups: + - serving.knative.dev + resources: + - services + verbs: + - delete + - get + - list + - patch + - watch diff --git a/controllers/idle_knative_cleanup_controller.go b/controllers/idle_knative_cleanup_controller.go index 0f285ad..1afe50f 100644 --- a/controllers/idle_knative_cleanup_controller.go +++ b/controllers/idle_knative_cleanup_controller.go @@ -17,10 +17,23 @@ limitations under the License. package controllers import ( + "context" + "errors" "fmt" "time" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) const ( @@ -30,6 +43,8 @@ const ( knativeServiceLabel = "serving.knative.dev/service" ) +var knativeServiceGVK = schema.GroupVersionKind{Group: "serving.knative.dev", Version: "v1", Kind: "Service"} + // isExcluded reports whether the Service opted out of idle cleanup via the // cleaner.vtex.io/exclude annotation. func isExcluded(svc *unstructured.Unstructured) bool { @@ -62,3 +77,129 @@ func readIdleSince(svc *unstructured.Unstructured) (time.Time, bool, error) { } return t, true, nil } + +// IdleKnativeCleanupReconciler deletes Knative Services that declare +// autoscaling.knative.dev/min-scale: "0" and have stayed scaled to zero +// replicas for longer than Threshold. +type IdleKnativeCleanupReconciler struct { + client.Client + Scheme *runtime.Scheme + + Recorder record.EventRecorder + + // Threshold is how long a Service may stay idle (all owned Deployments + // at 0 replicas) before it is deleted. + Threshold time.Duration +} + +//+kubebuilder:rbac:groups=serving.knative.dev,resources=services,verbs=get;list;watch;patch;delete +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +func (r *IdleKnativeCleanupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + + svc := &unstructured.Unstructured{} + svc.SetGroupVersionKind(knativeServiceGVK) + if err := r.Get(ctx, req.NamespacedName, svc); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if isExcluded(svc) { + return ctrl.Result{}, nil + } + + minScaleZero, err := hasMinScaleZero(svc) + if err != nil { + log.Error(err, "failed to read min-scale annotation") + return ctrl.Result{}, err + } + if !minScaleZero { + return ctrl.Result{}, nil + } + + candidate, err := r.deploymentsScaledToZero(ctx, svc.GetNamespace(), svc.GetName()) + if err != nil { + return ctrl.Result{}, fmt.Errorf("listing deployments for service %s/%s: %w", svc.GetNamespace(), svc.GetName(), err) + } + if !candidate { + return ctrl.Result{}, nil + } + + _, hasSince, err := readIdleSince(svc) + if err != nil { + log.Error(err, "invalid idle-since annotation, will overwrite it") + } + if hasSince { + return ctrl.Result{}, nil + } + + if err := r.patchIdleSince(ctx, svc, time.Now()); err != nil { + return ctrl.Result{}, err + } + r.Recorder.Eventf(svc, corev1.EventTypeNormal, "IdleServiceMarked", "Service marked idle, will be deleted after %s if it stays idle", r.Threshold) + return ctrl.Result{RequeueAfter: r.Threshold}, nil +} + +// deploymentsScaledToZero reports whether every Deployment labeled +// serving.knative.dev/service= in namespace has 0 replicas. +// It returns false if no such Deployment exists yet. +func (r *IdleKnativeCleanupReconciler) deploymentsScaledToZero(ctx context.Context, namespace, serviceName string) (bool, error) { + var deployments appsv1.DeploymentList + err := r.List(ctx, &deployments, + client.InNamespace(namespace), + client.MatchingLabels{knativeServiceLabel: serviceName}, + ) + if err != nil { + return false, err + } + if deployments.GetContinue() != "" { + return false, errors.New("r.List: unexpected continuation token") + } + if len(deployments.Items) == 0 { + return false, nil + } + for _, d := range deployments.Items { + if d.Status.Replicas != 0 { + return false, nil + } + } + return true, nil +} + +// patchIdleSince stamps the cleaner.vtex.io/idle-since annotation with t. +func (r *IdleKnativeCleanupReconciler) patchIdleSince(ctx context.Context, svc *unstructured.Unstructured, t time.Time) error { + patch := client.MergeFrom(svc.DeepCopy()) + anns := svc.GetAnnotations() + if anns == nil { + anns = map[string]string{} + } + anns[idleAnnotationIdleSince] = t.UTC().Format(time.RFC3339) + svc.SetAnnotations(anns) + return r.Patch(ctx, svc, patch) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *IdleKnativeCleanupReconciler) SetupWithManager(mgr ctrl.Manager) error { + svc := &unstructured.Unstructured{} + svc.SetGroupVersionKind(knativeServiceGVK) + return ctrl.NewControllerManagedBy(mgr). + For(svc). + Watches( + &appsv1.Deployment{}, + handler.EnqueueRequestsFromMapFunc(mapDeploymentToService), + ). + Complete(r) +} + +// mapDeploymentToService enqueues a reconcile for the Knative Service that +// owns obj, identified via the serving.knative.dev/service label. +func mapDeploymentToService(ctx context.Context, obj client.Object) []reconcile.Request { + name, ok := obj.GetLabels()[knativeServiceLabel] + if !ok { + return nil + } + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{Name: name, Namespace: obj.GetNamespace()}}, + } +} diff --git a/controllers/idle_knative_cleanup_controller_test.go b/controllers/idle_knative_cleanup_controller_test.go new file mode 100644 index 0000000..2d830e6 --- /dev/null +++ b/controllers/idle_knative_cleanup_controller_test.go @@ -0,0 +1,95 @@ +package controllers + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/pointer" +) + +func buildIdleKnativeService(name string, minScaleZero, exclude bool) *unstructured.Unstructured { + svc := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "serving.knative.dev/v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": name, + "namespace": ConditionalTTLNamespace, + }, + }} + if exclude { + svc.SetAnnotations(map[string]string{idleAnnotationExclude: "true"}) + } + templateAnns := map[string]string{} + if minScaleZero { + templateAnns[knativeMinScaleAnnotation] = "0" + } + err := unstructured.SetNestedStringMap(svc.Object, templateAnns, "spec", "template", "metadata", "annotations") + if err != nil { + panic(err) + } + return svc +} + +func buildIdleDeployment(name, serviceName string, replicas int32) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ConditionalTTLNamespace, + Labels: map[string]string{ + knativeServiceLabel: serviceName, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: pointer.Int32(replicas), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test-container", Image: "test-image"}, + }, + }, + }, + }, + } +} + +func getIdleSinceAnnotation(name string) (string, error) { + found := &unstructured.Unstructured{} + found.SetGroupVersionKind(knativeServiceGVK) + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: ConditionalTTLNamespace}, found); err != nil { + return "", err + } + return found.GetAnnotations()[idleAnnotationIdleSince], nil +} + +var _ = Describe("IdleKnativeCleanup controller", func() { + It("does not mark a Service without min-scale=0 as idle", func() { + name := "idle-no-minscale" + Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, false, false))).To(Succeed()) + Expect(k8sClient.Create(ctx, buildIdleDeployment(name+"-deployment", name, 0))).To(Succeed()) + + Consistently(func() (string, error) { + return getIdleSinceAnnotation(name) + }, duration, interval).Should(BeEmpty()) + }) + + It("marks idle-since when min-scale is 0 and all deployments are scaled to zero", func() { + name := "idle-candidate" + Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, true, false))).To(Succeed()) + Expect(k8sClient.Create(ctx, buildIdleDeployment(name+"-deployment", name, 0))).To(Succeed()) + + Eventually(func() (string, error) { + return getIdleSinceAnnotation(name) + }, timeout, interval).ShouldNot(BeEmpty()) + }) +}) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index ddb1987..2a19d82 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -104,7 +104,7 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases"), "testdata"}, ErrorIfCRDPathMissing: true, } @@ -146,6 +146,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&IdleKnativeCleanupReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: k8sManager.GetEventRecorderFor("cleaner-controller"), + Threshold: idleThreshold, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -166,6 +174,8 @@ const ( timeout = time.Second * 10 duration = time.Second * 10 interval = time.Millisecond * 250 + + idleThreshold = time.Second * 3 ) var ( diff --git a/controllers/testdata/serving.knative.dev_services.yaml b/controllers/testdata/serving.knative.dev_services.yaml new file mode 100644 index 0000000..fd92e6e --- /dev/null +++ b/controllers/testdata/serving.knative.dev_services.yaml @@ -0,0 +1,22 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: services.serving.knative.dev +spec: + group: serving.knative.dev + names: + kind: Service + listKind: ServiceList + plural: services + singular: service + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true From 8335c890e13481dac9a88c16f577bfd5e0327f6a Mon Sep 17 00:00:00 2001 From: William Sena Date: Wed, 2 Sep 2026 09:31:10 -0300 Subject: [PATCH 03/10] feat: delete idle Knative services past threshold, reset on reactivation Co-Authored-By: Claude Sonnet 5 --- .../idle_knative_cleanup_controller.go | 54 ++++++++++++++----- .../idle_knative_cleanup_controller_test.go | 42 +++++++++++++++ 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/controllers/idle_knative_cleanup_controller.go b/controllers/idle_knative_cleanup_controller.go index 1afe50f..cb30b69 100644 --- a/controllers/idle_knative_cleanup_controller.go +++ b/controllers/idle_knative_cleanup_controller.go @@ -24,6 +24,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -114,31 +115,51 @@ func (r *IdleKnativeCleanupReconciler) Reconcile(ctx context.Context, req ctrl.R log.Error(err, "failed to read min-scale annotation") return ctrl.Result{}, err } - if !minScaleZero { - return ctrl.Result{}, nil + + candidate := false + if minScaleZero { + candidate, err = r.deploymentsScaledToZero(ctx, svc.GetNamespace(), svc.GetName()) + if err != nil { + return ctrl.Result{}, fmt.Errorf("listing deployments for service %s/%s: %w", svc.GetNamespace(), svc.GetName(), err) + } } - candidate, err := r.deploymentsScaledToZero(ctx, svc.GetNamespace(), svc.GetName()) + since, hasSince, err := readIdleSince(svc) if err != nil { - return ctrl.Result{}, fmt.Errorf("listing deployments for service %s/%s: %w", svc.GetNamespace(), svc.GetName(), err) + log.Error(err, "invalid idle-since annotation, clearing it") + hasSince = false } + if !candidate { + if hasSince { + if err := r.clearIdleSince(ctx, svc); err != nil { + return ctrl.Result{}, err + } + r.Recorder.Eventf(svc, corev1.EventTypeNormal, "IdleServiceReactivated", "Service is no longer idle, idle-since cleared") + } return ctrl.Result{}, nil } - _, hasSince, err := readIdleSince(svc) - if err != nil { - log.Error(err, "invalid idle-since annotation, will overwrite it") + now := time.Now() + if !hasSince { + if err := r.patchIdleSince(ctx, svc, now); err != nil { + return ctrl.Result{}, err + } + r.Recorder.Eventf(svc, corev1.EventTypeNormal, "IdleServiceMarked", "Service marked idle, will be deleted after %s if it stays idle", r.Threshold) + return ctrl.Result{RequeueAfter: r.Threshold}, nil } - if hasSince { - return ctrl.Result{}, nil + + elapsed := now.Sub(since) + if elapsed < r.Threshold { + return ctrl.Result{RequeueAfter: r.Threshold - elapsed}, nil } - if err := r.patchIdleSince(ctx, svc, time.Now()); err != nil { + if err := r.Delete(ctx, svc); err != nil && !apierrors.IsNotFound(err) { + r.Recorder.Eventf(svc, corev1.EventTypeWarning, "IdleServiceDeleteFailed", "Error deleting idle service: %s", err.Error()) return ctrl.Result{}, err } - r.Recorder.Eventf(svc, corev1.EventTypeNormal, "IdleServiceMarked", "Service marked idle, will be deleted after %s if it stays idle", r.Threshold) - return ctrl.Result{RequeueAfter: r.Threshold}, nil + r.Recorder.Eventf(svc, corev1.EventTypeNormal, "IdleServiceDeleted", "Service deleted after being idle for %s", elapsed.Round(time.Second)) + return ctrl.Result{}, nil } // deploymentsScaledToZero reports whether every Deployment labeled @@ -179,6 +200,15 @@ func (r *IdleKnativeCleanupReconciler) patchIdleSince(ctx context.Context, svc * return r.Patch(ctx, svc, patch) } +// clearIdleSince removes the cleaner.vtex.io/idle-since annotation. +func (r *IdleKnativeCleanupReconciler) clearIdleSince(ctx context.Context, svc *unstructured.Unstructured) error { + patch := client.MergeFrom(svc.DeepCopy()) + anns := svc.GetAnnotations() + delete(anns, idleAnnotationIdleSince) + svc.SetAnnotations(anns) + return r.Patch(ctx, svc, patch) +} + // SetupWithManager sets up the controller with the Manager. func (r *IdleKnativeCleanupReconciler) SetupWithManager(mgr ctrl.Manager) error { svc := &unstructured.Unstructured{} diff --git a/controllers/idle_knative_cleanup_controller_test.go b/controllers/idle_knative_cleanup_controller_test.go index 2d830e6..5bd2793 100644 --- a/controllers/idle_knative_cleanup_controller_test.go +++ b/controllers/idle_knative_cleanup_controller_test.go @@ -92,4 +92,46 @@ var _ = Describe("IdleKnativeCleanup controller", func() { return getIdleSinceAnnotation(name) }, timeout, interval).ShouldNot(BeEmpty()) }) + + It("deletes the Service once it has been idle past the threshold", func() { + name := "idle-expired" + Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, true, false))).To(Succeed()) + Expect(k8sClient.Create(ctx, buildIdleDeployment(name+"-deployment", name, 0))).To(Succeed()) + + Eventually(func() error { + found := &unstructured.Unstructured{} + found.SetGroupVersionKind(knativeServiceGVK) + return k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: ConditionalTTLNamespace}, found) + }, idleThreshold*3, interval).ShouldNot(Succeed()) + }) + + It("clears idle-since when the underlying deployment scales back up", func() { + name := "idle-reactivated" + depName := name + "-deployment" + Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, true, false))).To(Succeed()) + Expect(k8sClient.Create(ctx, buildIdleDeployment(depName, name, 0))).To(Succeed()) + + Eventually(func() (string, error) { + return getIdleSinceAnnotation(name) + }, timeout, interval).ShouldNot(BeEmpty()) + + foundDep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: depName, Namespace: ConditionalTTLNamespace}, foundDep)).To(Succeed()) + foundDep.Status.Replicas = 1 + Expect(k8sClient.Status().Update(ctx, foundDep)).To(Succeed()) + + Eventually(func() (string, error) { + return getIdleSinceAnnotation(name) + }, timeout, interval).Should(BeEmpty()) + }) + + It("never marks an excluded Service as idle", func() { + name := "idle-excluded" + Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, true, true))).To(Succeed()) + Expect(k8sClient.Create(ctx, buildIdleDeployment(name+"-deployment", name, 0))).To(Succeed()) + + Consistently(func() (string, error) { + return getIdleSinceAnnotation(name) + }, duration, interval).Should(BeEmpty()) + }) }) From a44e5dfcb2fe03354bb9703078d0f7d6bf6fe4ae Mon Sep 17 00:00:00 2001 From: William Sena Date: Wed, 2 Sep 2026 09:31:46 -0300 Subject: [PATCH 04/10] feat: gate idle Knative cleanup behind IDLE_KNATIVE_CLEANUP_ENABLED Co-Authored-By: Claude Sonnet 5 --- main.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/main.go b/main.go index bd7107e..73efd0b 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,8 @@ package main import ( "flag" "os" + "time" + "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" @@ -124,6 +126,27 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "ConditionalTTL") os.Exit(1) } + + if os.Getenv("IDLE_KNATIVE_CLEANUP_ENABLED") == "true" { + thresholdStr := os.Getenv("IDLE_KNATIVE_CLEANUP_THRESHOLD") + if thresholdStr == "" { + thresholdStr = "12h" + } + threshold, err := time.ParseDuration(thresholdStr) + if err != nil { + setupLog.Error(err, "invalid IDLE_KNATIVE_CLEANUP_THRESHOLD", "value", thresholdStr) + os.Exit(1) + } + if err = (&controllers.IdleKnativeCleanupReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("cleaner-controller"), + Threshold: threshold, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "IdleKnativeCleanup") + os.Exit(1) + } + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { From 3553928cbeebf901475859b4e29cf2eb2ca81e43 Mon Sep 17 00:00:00 2001 From: William Sena Date: Wed, 2 Sep 2026 09:32:58 -0300 Subject: [PATCH 05/10] docs: document idle Knative cleanup env vars Co-Authored-By: Claude Sonnet 5 --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 56367e3..44969f1 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,19 @@ Introductory reading: ```bash make run ``` - + +### Idle Knative Service cleanup + +Deletes Knative Services declaring `autoscaling.knative.dev/min-scale: "0"` +once all their Deployments have been at 0 replicas for longer than a +configurable threshold. Disabled by default; opt in per cluster with env +vars (no rebuild required): + +| Env var | Default | Description | +|---|---|---| +| `IDLE_KNATIVE_CLEANUP_ENABLED` | `false` | Set to `true` to register the controller. | +| `IDLE_KNATIVE_CLEANUP_THRESHOLD` | `12h` | Go duration string (e.g. `6h`, `30m`) a Service may stay idle before deletion. | + +Opt a specific Service out with the `cleaner.vtex.io/exclude: "true"` +annotation. Edit the [controller code](./controllers/idle_knative_cleanup_controller.go). + From 5b7bd296609e21ec591f5ddc162662c96f1cdcc8 Mon Sep 17 00:00:00 2001 From: William Sena Date: Fri, 4 Sep 2026 17:38:50 -0300 Subject: [PATCH 06/10] feat: make IDLE_KNATIVE_CLEANUP_ENABLED a mode switch, not additive A cluster runs cleanup by either ConditionalTTL's creation-timestamp TTL or Knative idle detection, never both at once. Co-Authored-By: Claude Sonnet 5 --- README.md | 9 ++++++--- main.go | 27 +++++++++++++++++---------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 44969f1..2ebc653 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,15 @@ Introductory reading: Deletes Knative Services declaring `autoscaling.knative.dev/min-scale: "0"` once all their Deployments have been at 0 replicas for longer than a -configurable threshold. Disabled by default; opt in per cluster with env -vars (no rebuild required): +configurable threshold. `IDLE_KNATIVE_CLEANUP_ENABLED` is a **mode switch**, +not an additive toggle: a cluster runs cleanup by either `ConditionalTTL`'s +creation-timestamp TTL or by idle detection, never both. Disabled by +default (so existing clusters keep running `ConditionalTTL` unchanged); +opt in per cluster with env vars (no rebuild required): | Env var | Default | Description | |---|---|---| -| `IDLE_KNATIVE_CLEANUP_ENABLED` | `false` | Set to `true` to register the controller. | +| `IDLE_KNATIVE_CLEANUP_ENABLED` | `false` | Set to `true` to switch this cluster to idle-based cleanup — this also stops the `ConditionalTTL` reconciler from running. | | `IDLE_KNATIVE_CLEANUP_THRESHOLD` | `12h` | Go duration string (e.g. `6h`, `30m`) a Service may stay idle before deletion. | Opt a specific Service out with the `cleaner.vtex.io/exclude: "true"` diff --git a/main.go b/main.go index 73efd0b..5183b1f 100644 --- a/main.go +++ b/main.go @@ -116,18 +116,25 @@ func main() { os.Exit(1) } - if err = (&controllers.ConditionalTTLReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Config: mgr.GetConfig(), - Recorder: mgr.GetEventRecorderFor("cleaner-controller"), - CloudEventsClient: cec, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "ConditionalTTL") - os.Exit(1) + // IDLE_KNATIVE_CLEANUP_ENABLED is a mode switch, not an additive toggle: + // a cluster runs cleanup by either ConditionalTTL's creation-timestamp + // TTL or by Knative idle detection, never both. + idleCleanupEnabled := os.Getenv("IDLE_KNATIVE_CLEANUP_ENABLED") == "true" + + if !idleCleanupEnabled { + if err = (&controllers.ConditionalTTLReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Config: mgr.GetConfig(), + Recorder: mgr.GetEventRecorderFor("cleaner-controller"), + CloudEventsClient: cec, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ConditionalTTL") + os.Exit(1) + } } - if os.Getenv("IDLE_KNATIVE_CLEANUP_ENABLED") == "true" { + if idleCleanupEnabled { thresholdStr := os.Getenv("IDLE_KNATIVE_CLEANUP_THRESHOLD") if thresholdStr == "" { thresholdStr = "12h" From 0ea4b45bb70c667ae3b22a70e1df678f40c96328 Mon Sep 17 00:00:00 2001 From: William Sena Date: Fri, 4 Sep 2026 17:47:49 -0300 Subject: [PATCH 07/10] chore: gitignore brainstorming design specs and plans directories Both are local working docs for the brainstorming/writing-plans workflow, not artifacts meant to live in the repo history. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 7de190d..d5c565b 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ Dockerfile.cross # .specify/memory/ # .specify/specs/ # .specify/templates/overrides/ + +# Brainstorming design specs and plans - local working docs, not committed +docs/superpowers/specs/ +docs/superpowers/plans/ From 03df6ac6069843b98f65fed87988917d29de928f Mon Sep 17 00:00:00 2001 From: William Sena Date: Fri, 4 Sep 2026 17:53:23 -0300 Subject: [PATCH 08/10] revert: run idle cleanup alongside ConditionalTTL, not instead of it IDLE_KNATIVE_CLEANUP_ENABLED only turns the idle-cleanup controller on or off; ConditionalTTL's creation-timestamp TTL cleanup always runs, same as before this feature was added. Co-Authored-By: Claude Sonnet 5 --- README.md | 12 ++++++------ main.go | 27 ++++++++++----------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2ebc653..56afb22 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,15 @@ Introductory reading: Deletes Knative Services declaring `autoscaling.knative.dev/min-scale: "0"` once all their Deployments have been at 0 replicas for longer than a -configurable threshold. `IDLE_KNATIVE_CLEANUP_ENABLED` is a **mode switch**, -not an additive toggle: a cluster runs cleanup by either `ConditionalTTL`'s -creation-timestamp TTL or by idle detection, never both. Disabled by -default (so existing clusters keep running `ConditionalTTL` unchanged); -opt in per cluster with env vars (no rebuild required): +configurable threshold. This runs alongside `ConditionalTTL`'s +creation-timestamp TTL cleanup, not instead of it — both mechanisms are +active at the same time; `IDLE_KNATIVE_CLEANUP_ENABLED` only turns the idle +mechanism on or off. Disabled by default; opt in per cluster with env vars +(no rebuild required): | Env var | Default | Description | |---|---|---| -| `IDLE_KNATIVE_CLEANUP_ENABLED` | `false` | Set to `true` to switch this cluster to idle-based cleanup — this also stops the `ConditionalTTL` reconciler from running. | +| `IDLE_KNATIVE_CLEANUP_ENABLED` | `false` | Set to `true` to also register the idle-cleanup controller. | | `IDLE_KNATIVE_CLEANUP_THRESHOLD` | `12h` | Go duration string (e.g. `6h`, `30m`) a Service may stay idle before deletion. | Opt a specific Service out with the `cleaner.vtex.io/exclude: "true"` diff --git a/main.go b/main.go index 5183b1f..73efd0b 100644 --- a/main.go +++ b/main.go @@ -116,25 +116,18 @@ func main() { os.Exit(1) } - // IDLE_KNATIVE_CLEANUP_ENABLED is a mode switch, not an additive toggle: - // a cluster runs cleanup by either ConditionalTTL's creation-timestamp - // TTL or by Knative idle detection, never both. - idleCleanupEnabled := os.Getenv("IDLE_KNATIVE_CLEANUP_ENABLED") == "true" - - if !idleCleanupEnabled { - if err = (&controllers.ConditionalTTLReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Config: mgr.GetConfig(), - Recorder: mgr.GetEventRecorderFor("cleaner-controller"), - CloudEventsClient: cec, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "ConditionalTTL") - os.Exit(1) - } + if err = (&controllers.ConditionalTTLReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Config: mgr.GetConfig(), + Recorder: mgr.GetEventRecorderFor("cleaner-controller"), + CloudEventsClient: cec, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ConditionalTTL") + os.Exit(1) } - if idleCleanupEnabled { + if os.Getenv("IDLE_KNATIVE_CLEANUP_ENABLED") == "true" { thresholdStr := os.Getenv("IDLE_KNATIVE_CLEANUP_THRESHOLD") if thresholdStr == "" { thresholdStr = "12h" From 1d5bcaa6efc2f1de16dee70a5083b43f364ea681 Mon Sep 17 00:00:00 2001 From: William Sena Date: Fri, 4 Sep 2026 17:56:40 -0300 Subject: [PATCH 09/10] feat: expose idle-cleanup env vars in the manager Deployment manifest Without these, the generated Helm chart had no way to set IDLE_KNATIVE_CLEANUP_ENABLED/_THRESHOLD at install/upgrade time - required to actually turn the feature on in a cluster. Co-Authored-By: Claude Sonnet 5 --- config/manager/manager.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 9a12a92..ee8f73a 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -74,6 +74,11 @@ spec: - --leader-elect image: controller:latest name: manager + env: + - name: IDLE_KNATIVE_CLEANUP_ENABLED + value: "false" + - name: IDLE_KNATIVE_CLEANUP_THRESHOLD + value: "12h" securityContext: allowPrivilegeEscalation: false capabilities: From cfc85436ebfaeda179d3e9dc7ce5f905a6055b0d Mon Sep 17 00:00:00 2001 From: William Sena Date: Thu, 10 Sep 2026 17:00:33 -0300 Subject: [PATCH 10/10] fix: guard idle cleanup against cold-start scale-up and add C4 diagrams Require both spec and status replicas to be zero before marking a Knative Service idle, preventing deletion during scale-from-zero. Add LikeC4 architecture docs with make diagram to visualize cleanup paths and the idle timer reset behavior. Co-authored-by: Cursor --- .gitignore | 6 + Makefile | 4 + README.md | 4 + .../idle_knative_cleanup_controller.go | 13 +- .../idle_knative_cleanup_controller_test.go | 34 + .../idle_knative_cleanup_helpers_test.go | 28 + docs/architecture/likec4/README.md | 57 + docs/architecture/likec4/model.c4 | 178 ++ docs/architecture/likec4/package-lock.json | 1910 +++++++++++++++++ docs/architecture/likec4/package.json | 12 + docs/architecture/likec4/views.c4 | 111 + 11 files changed, 2356 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/likec4/README.md create mode 100644 docs/architecture/likec4/model.c4 create mode 100644 docs/architecture/likec4/package-lock.json create mode 100644 docs/architecture/likec4/package.json create mode 100644 docs/architecture/likec4/views.c4 diff --git a/.gitignore b/.gitignore index d5c565b..a4c5ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,9 @@ Dockerfile.cross # Brainstorming design specs and plans - local working docs, not committed docs/superpowers/specs/ docs/superpowers/plans/ + +# LikeC4 architecture diagram tooling (docs/architecture/likec4/) -- +# package-lock.json is kept for reproducible installs, node_modules isn't. +docs/architecture/likec4/node_modules/ +docs/architecture/likec4/dist/ +docs/architecture/likec4/images/ diff --git a/Makefile b/Makefile index e5a6b82..1488fe0 100644 --- a/Makefile +++ b/Makefile @@ -119,6 +119,10 @@ gen-docs: crd-ref-docs ## Generates Markdown API Reference. $(CRD_REF_DOCS) --source-path=./api/v1alpha1 --renderer=markdown mv out.md ./docs/api-reference.md +.PHONY: diagram +diagram: ## Preview the C4 architecture diagram locally (http://localhost:5173) + @cd docs/architecture/likec4 && npm install && npx likec4 start + ##@ Build .PHONY: build diff --git a/README.md b/README.md index 56afb22..54f90e9 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,7 @@ mechanism on or off. Disabled by default; opt in per cluster with env vars Opt a specific Service out with the `cleaner.vtex.io/exclude: "true"` annotation. Edit the [controller code](./controllers/idle_knative_cleanup_controller.go). +See the [architecture diagram](./docs/architecture/likec4/README.md) for a +visual overview of what each cleanup mechanism deletes and how the idle timer +resets on scale-up (`make diagram`). + diff --git a/controllers/idle_knative_cleanup_controller.go b/controllers/idle_knative_cleanup_controller.go index cb30b69..f94298c 100644 --- a/controllers/idle_knative_cleanup_controller.go +++ b/controllers/idle_knative_cleanup_controller.go @@ -162,6 +162,17 @@ func (r *IdleKnativeCleanupReconciler) Reconcile(ctx context.Context, req ctrl.R return ctrl.Result{}, nil } +// deploymentReplicasAtZero reports whether a Deployment is fully scaled to +// zero. Both spec and status must be zero: status alone is insufficient +// because Knative sets spec.replicas before pods appear during a cold start. +func deploymentReplicasAtZero(d appsv1.Deployment) bool { + desired := int32(0) + if d.Spec.Replicas != nil { + desired = *d.Spec.Replicas + } + return desired == 0 && d.Status.Replicas == 0 +} + // deploymentsScaledToZero reports whether every Deployment labeled // serving.knative.dev/service= in namespace has 0 replicas. // It returns false if no such Deployment exists yet. @@ -181,7 +192,7 @@ func (r *IdleKnativeCleanupReconciler) deploymentsScaledToZero(ctx context.Conte return false, nil } for _, d := range deployments.Items { - if d.Status.Replicas != 0 { + if !deploymentReplicasAtZero(d) { return false, nil } } diff --git a/controllers/idle_knative_cleanup_controller_test.go b/controllers/idle_knative_cleanup_controller_test.go index 5bd2793..07bbd0d 100644 --- a/controllers/idle_knative_cleanup_controller_test.go +++ b/controllers/idle_knative_cleanup_controller_test.go @@ -117,7 +117,9 @@ var _ = Describe("IdleKnativeCleanup controller", func() { foundDep := &appsv1.Deployment{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: depName, Namespace: ConditionalTTLNamespace}, foundDep)).To(Succeed()) + foundDep.Spec.Replicas = pointer.Int32(1) foundDep.Status.Replicas = 1 + Expect(k8sClient.Update(ctx, foundDep)).To(Succeed()) Expect(k8sClient.Status().Update(ctx, foundDep)).To(Succeed()) Eventually(func() (string, error) { @@ -125,6 +127,38 @@ var _ = Describe("IdleKnativeCleanup controller", func() { }, timeout, interval).Should(BeEmpty()) }) + It("clears idle-since during a cold start when spec scales up before status", func() { + name := "idle-cold-start" + depName := name + "-deployment" + Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, true, false))).To(Succeed()) + Expect(k8sClient.Create(ctx, buildIdleDeployment(depName, name, 0))).To(Succeed()) + + Eventually(func() (string, error) { + return getIdleSinceAnnotation(name) + }, timeout, interval).ShouldNot(BeEmpty()) + + foundDep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: depName, Namespace: ConditionalTTLNamespace}, foundDep)).To(Succeed()) + foundDep.Spec.Replicas = pointer.Int32(1) + Expect(k8sClient.Update(ctx, foundDep)).To(Succeed()) + + Eventually(func() (string, error) { + return getIdleSinceAnnotation(name) + }, timeout, interval).Should(BeEmpty()) + }) + + It("does not mark idle when deployment spec requests replicas during cold start", func() { + name := "idle-cold-start-pending" + dep := buildIdleDeployment(name+"-deployment", name, 1) + dep.Status.Replicas = 0 + Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, true, false))).To(Succeed()) + Expect(k8sClient.Create(ctx, dep)).To(Succeed()) + + Consistently(func() (string, error) { + return getIdleSinceAnnotation(name) + }, duration, interval).Should(BeEmpty()) + }) + It("never marks an excluded Service as idle", func() { name := "idle-excluded" Expect(k8sClient.Create(ctx, buildIdleKnativeService(name, true, true))).To(Succeed()) diff --git a/controllers/idle_knative_cleanup_helpers_test.go b/controllers/idle_knative_cleanup_helpers_test.go index 55c8c0d..0c37c19 100644 --- a/controllers/idle_knative_cleanup_helpers_test.go +++ b/controllers/idle_knative_cleanup_helpers_test.go @@ -4,7 +4,9 @@ import ( "testing" "time" + appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/pointer" ) func newTestService() *unstructured.Unstructured { @@ -105,3 +107,29 @@ func TestReadIdleSince(t *testing.T) { } }) } + +func TestDeploymentReplicasAtZero(t *testing.T) { + cases := []struct { + name string + specReplicas *int32 + statusReplicas int32 + want bool + }{ + {name: "fully idle", specReplicas: pointer.Int32(0), statusReplicas: 0, want: true}, + {name: "nil spec treated as zero", specReplicas: nil, statusReplicas: 0, want: true}, + {name: "cold start spec scaled up status pending", specReplicas: pointer.Int32(1), statusReplicas: 0, want: false}, + {name: "scale down in progress", specReplicas: pointer.Int32(0), statusReplicas: 1, want: false}, + {name: "running", specReplicas: pointer.Int32(2), statusReplicas: 2, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dep := appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{Replicas: tc.specReplicas}, + Status: appsv1.DeploymentStatus{Replicas: tc.statusReplicas}, + } + if got := deploymentReplicasAtZero(dep); got != tc.want { + t.Errorf("deploymentReplicasAtZero() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/docs/architecture/likec4/README.md b/docs/architecture/likec4/README.md new file mode 100644 index 0000000..b47aae1 --- /dev/null +++ b/docs/architecture/likec4/README.md @@ -0,0 +1,57 @@ +# Architecture Diagram (C4 / LikeC4) + +A [C4 model](https://c4model.com/) of **cleaner-controller** — the Kubernetes +operator that manages resource lifecycle via `ConditionalTTL` CRs and, when +enabled, idle Knative Service cleanup. Written in [LikeC4](https://likec4.dev/) +DSL (`model.c4`, `views.c4`). + +## Views + +| View | What it shows | +|---|---| +| **`index`** | System context: both cleanup mechanisms and external dependencies | +| **`whatGetsDeleted`** | Side-by-side comparison of what each mechanism removes | +| **`conditionalTTLComponents`** | ConditionalTTL reconciler: CEL, target/release/cloud-event finalizers | +| **`idleCleanupComponents`** | Idle cleanup: detector, `idle-since` annotator, deleter | +| **`idleTimerReset`** | Sequence: scale-up at 3h **resets** the clock; deletion only after 12h of a **new** continuous idle period | +| **`idleContinuousDeletion`** | Sequence: uninterrupted 12h at zero → delete | + +## Viewing it + +From the repo root: + +```bash +make diagram # http://localhost:5173 +``` + +Or directly: + +```bash +cd docs/architecture/likec4 +npm install +npx likec4 start +``` + +Or open the `.c4` files with the +[LikeC4 VS Code extension](https://marketplace.visualstudio.com/items?itemName=likec4.likec4-vscode). + +To export static images: + +```bash +npx playwright install # one-time, needed for image export +npx likec4 export png -o images +``` + +## Idle timer FAQ + +**Q: Service scaled to zero, stayed idle 3h, then scaled to 1. Is it deleted after 12h from the first zero?** + +**No.** Scale-up clears `cleaner.vtex.io/idle-since`. Deletion only happens after +`IDLE_KNATIVE_CLEANUP_THRESHOLD` (default `12h`) of **continuous** idle time +starting from the **latest** moment all Deployments returned to +`spec.replicas=0` and `status.replicas=0`. + +## Keeping this up to date + +Update `model.c4` / `views.c4` in the same PR as architectural changes to the +controllers or cleanup semantics. diff --git a/docs/architecture/likec4/model.c4 b/docs/architecture/likec4/model.c4 new file mode 100644 index 0000000..4ee3467 --- /dev/null +++ b/docs/architecture/likec4/model.c4 @@ -0,0 +1,178 @@ +specification { + // VTEX brand palette — https://vtex.com/en-us/brand-guidelines/ + color vtexPink #F71963 + color vtexBlack #142032 + color vtexGray #787C89 + color vtexCoolGray #C3C6CC + color vtexSoftBlue #F5F9FF + color vtexGreen #2E7D32 + color vtexRed #C62828 + color vtexAmber #F9A825 + + element person { + style { + shape person + color vtexCoolGray + size xs + } + } + element softwareSystem + element container + element component +} + +model { + platformEngineer = person 'Platform Engineer' 'Creates ConditionalTTL CRs and enables idle cleanup per cluster' { + style { + icon tech:javascript + } + } + + tenantTraffic = person 'Tenant traffic' 'HTTP requests that may scale a Knative Service up from zero' { + style { + icon tech:javascript + } + } + + cleanerController = softwareSystem 'cleaner-controller' 'Kubernetes operator that deletes resources after TTL expiry or prolonged scale-to-zero idle time' { + style { + color vtexBlack + icon tech:kubernetes + } + + manager = container 'Manager Pod' 'controller-runtime manager; registers reconcilers based on env vars' 'Go' { + style { + color vtexBlack + icon tech:go + } + + conditionalTTLReconciler = component 'ConditionalTTLReconciler' 'Watches ConditionalTTL CRs. After spec.ttl from creationTimestamp and optional CEL conditions, deletes targets, uninstalls Helm releases, and emits CloudEvents' 'Go' { + style { + color vtexBlack + icon tech:go + } + + celEvaluator = component 'custom_cel' 'Compiles and evaluates CEL conditions against resolved targets' 'Go, CEL' { + style { + color vtexGray + } + } + + targetFinalizer = component 'target-finalizer' 'Deletes label-selected Kubernetes objects listed in spec.targets' 'Go' { + style { + color vtexRed + } + } + + releaseFinalizer = component 'release-finalizer' 'Uninstalls Helm releases referenced in spec.release' 'Go, Helm SDK' { + style { + color vtexRed + } + } + + cloudEventFinalizer = component 'cloud-event-finalizer' 'Sends a CloudEvent before the ConditionalTTL object is removed' 'Go, CloudEvents' { + style { + color vtexAmber + } + } + + reconcileLoop = component 'Reconcile loop' 'TTL expiry gate, then CEL evaluation, then finalizers in order' 'Go' { + style { + color vtexBlack + } + } + + reconcileLoop -> celEvaluator 'evaluates conditions' + reconcileLoop -> targetFinalizer 'deletes matched resources' + reconcileLoop -> releaseFinalizer 'helm uninstall' + reconcileLoop -> cloudEventFinalizer 'notify downstream' + } + + idleKnativeReconciler = component 'IdleKnativeCleanupReconciler' 'Opt-in via IDLE_KNATIVE_CLEANUP_ENABLED. Deletes Knative Services with min-scale=0 that stayed fully scaled to zero for IDLE_KNATIVE_CLEANUP_THRESHOLD (default 12h). Tracks idle time with cleaner.vtex.io/idle-since; resets on scale-up.' 'Go' { + style { + color vtexBlack + icon tech:go + } + + idleDetector = component 'idle detector' 'Requires min-scale=0, no cleaner.vtex.io/exclude, and every owned Deployment with spec.replicas=0 AND status.replicas=0' 'Go' { + style { + color vtexGray + } + } + + idleAnnotator = component 'idle-since annotator' 'Stamps cleaner.vtex.io/idle-since when idle starts; clears it on reactivation or cold start (spec>0)' 'Go' { + style { + color vtexAmber + } + } + + idleDeleter = component 'idle deleter' 'Deletes the Knative Service once idle-since + threshold elapsed' 'Go' { + style { + color vtexRed + } + } + + idleDetector -> idleAnnotator 'stamp / clear idle-since' + idleAnnotator -> idleDeleter 'delete after threshold' + } + } + } + + kubernetes = softwareSystem 'Kubernetes API' 'Cluster state: CRDs, Deployments, Knative Services, Helm secrets' { + style { + color vtexGray + icon tech:kubernetes + } + + conditionalTTLCR = component 'ConditionalTTL CR' 'cleaner.vtex.io/v1alpha1 — defines TTL, targets, optional CEL, Helm release, CloudEvent' { + style { + color vtexSoftBlue + } + } + + knativeService = component 'Knative Service' 'serving.knative.dev/v1 Service — deleted by idle cleanup when abandoned at zero' { + style { + color vtexRed + } + } + + knativeDeployment = component 'Knative Deployment' 'apps/v1 Deployment labeled serving.knative.dev/service= — watched to detect idle vs cold start' { + style { + color vtexGray + } + } + + arbitraryTarget = component 'Target resources' 'Any API object matched by a ConditionalTTL label selector — deleted by target-finalizer' { + style { + color vtexRed + } + } + + helmRelease = component 'Helm release' 'Release tracked in cluster secrets — uninstalled by release-finalizer' { + style { + color vtexRed + } + } + } + + cloudEventsSink = softwareSystem 'CloudEvents sink' 'Receives cleanup notifications from ConditionalTTL' { + style { + color vtexAmber + icon tech:kafka + } + } + + platformEngineer -> cleanerController.manager.conditionalTTLReconciler 'creates ConditionalTTL CRs' + platformEngineer -> cleanerController.manager.idleKnativeReconciler 'sets IDLE_KNATIVE_CLEANUP_ENABLED / THRESHOLD' + + cleanerController.manager.conditionalTTLReconciler -> kubernetes.conditionalTTLCR 'watch / update status' + cleanerController.manager.conditionalTTLReconciler -> kubernetes.arbitraryTarget 'delete after TTL + conditions' + cleanerController.manager.conditionalTTLReconciler -> kubernetes.helmRelease 'helm uninstall' + cleanerController.manager.conditionalTTLReconciler -> cloudEventsSink 'POST CloudEvent' + + cleanerController.manager.idleKnativeReconciler -> kubernetes.knativeService 'watch / patch idle-since / delete' + cleanerController.manager.idleKnativeReconciler -> kubernetes.knativeDeployment 'watch scale state' + + tenantTraffic -> kubernetes.knativeService 'traffic may scale from zero' + kubernetes.knativeService -> kubernetes.knativeDeployment 'owns Deployments' +} diff --git a/docs/architecture/likec4/package-lock.json b/docs/architecture/likec4/package-lock.json new file mode 100644 index 0000000..1b0646a --- /dev/null +++ b/docs/architecture/likec4/package-lock.json @@ -0,0 +1,1910 @@ +{ + "name": "cleaner-controller-architecture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cleaner-controller-architecture", + "devDependencies": { + "likec4": "^1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hpcc-js/wasm-graphviz": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.22.2.tgz", + "integrity": "sha512-qofkC1bxiQKljs95A/7a0j3mvjEdTBiDPq2W6Eh3mJGOLJ+CEtLVe5pFtzf+FZhYW/V9p9hssS1TRl9PxoV8Sw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@likec4/core": { + "version": "1.59.3", + "resolved": "https://registry.npmjs.org/@likec4/core/-/core-1.59.3.tgz", + "integrity": "sha512-5YXqyZKeZ4YoKrAArTTTWFBqI8NJhoMqOpC9N3/Z6S8l6Pa+K0SnghxoVseWniUDdvMV5I/u5Y7m/N+DCn7iJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immer": "^11.1.18", + "type-fest": "^4.41.0", + "zod": "^4.4.3" + } + }, + "node_modules/@likec4/icons": { + "version": "1.46.4", + "resolved": "https://registry.npmjs.org/@likec4/icons/-/icons-1.46.4.tgz", + "integrity": "sha512-GAL7aW53Mq3RnbFGK8BxHi/vGf73F7ODqgf7yPqfv/Kslt7KxHoxr/ZDIHcSo0oF4tXhpL7GYlpsgY71UJseqw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.0.tgz", + "integrity": "sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/likec4": { + "version": "1.59.3", + "resolved": "https://registry.npmjs.org/likec4/-/likec4-1.59.3.tgz", + "integrity": "sha512-ajgVOIZ/uXWHg3kfuMkhxz6ShcsQx+Wj1jJ2NIDckzAcTFDzeqAX3nuVXEh4gH5rCcU90vrSk6lDTUHgn0Qlog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hpcc-js/wasm-graphviz": "1.22.2", + "@likec4/core": "1.59.3", + "@likec4/icons": "1.46.4", + "@vitejs/plugin-react": "^6.1.1", + "bundle-require": "^5.1.0", + "chokidar": "^5.0.0", + "esbuild": "0.28.1", + "fdir": "6.4.0", + "immer": "^11.1.18", + "nano-spawn": "^2.1.0", + "playwright": "1.60.0", + "std-env": "^4.1.0", + "type-fest": "^4.41.0", + "use-sync-external-store": "^1.6.0", + "vite": "^8.2.2", + "vite-plugin-singlefile": "^2.3.3", + "yargs": "17.7.2" + }, + "bin": { + "likec4": "bin/likec4.mjs" + }, + "engines": { + "node": ">=22.22.3" + }, + "peerDependencies": { + "@tanstack/ai": "^0.14.0", + "@tanstack/ai-anthropic": "^0.8.3", + "@tanstack/ai-gemini": "^0.10.0", + "@tanstack/ai-ollama": "^0.6.10", + "@tanstack/ai-openai": "^0.8.2", + "@tanstack/ai-openrouter": "^0.8.2", + "react": "^19.2.x", + "react-dom": "^19.2.x" + }, + "peerDependenciesMeta": { + "@tanstack/ai": { + "optional": true + }, + "@tanstack/ai-anthropic": { + "optional": true + }, + "@tanstack/ai-gemini": { + "optional": true + }, + "@tanstack/ai-ollama": { + "optional": true + }, + "@tanstack/ai-openai": { + "optional": true + }, + "@tanstack/ai-openrouter": { + "optional": true + } + } + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/nano-spawn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.1.0.tgz", + "integrity": "sha512-yTW+2okrElHiH4fsiz/+/zc0EDo9BDDoC3iKk8dpv1GeRc9nUWzUZHx6TofMWErchhUQR8hY9/Eu1Uja9x1nqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/docs/architecture/likec4/package.json b/docs/architecture/likec4/package.json new file mode 100644 index 0000000..fc784a1 --- /dev/null +++ b/docs/architecture/likec4/package.json @@ -0,0 +1,12 @@ +{ + "name": "cleaner-controller-architecture", + "private": true, + "scripts": { + "start": "likec4 start", + "build": "likec4 build -o dist", + "export": "likec4 export png -o images" + }, + "devDependencies": { + "likec4": "^1" + } +} diff --git a/docs/architecture/likec4/views.c4 b/docs/architecture/likec4/views.c4 new file mode 100644 index 0000000..9d7b0c9 --- /dev/null +++ b/docs/architecture/likec4/views.c4 @@ -0,0 +1,111 @@ +views { + view index { + title 'cleaner-controller — System Context' + description ''' + Two independent cleanup paths run in the same operator: + + **ConditionalTTL** — age-based (TTL since creation) + optional CEL. + Deletes arbitrary targets, Helm releases, and emits CloudEvents. + + **Idle Knative cleanup** — opt-in, activity-based. + Deletes Knative Services that stayed at zero replicas for the threshold. + ''' + include * + } + + view whatGetsDeleted { + title 'What cleaner-controller deletes' + description ''' + | Mechanism | Trigger | What is removed | + |---|---|---| + | ConditionalTTL | `creationTimestamp + spec.ttl` and CEL conditions met | Label-selected K8s objects, Helm release, then the CR itself | + | Idle cleanup | `min-scale=0`, all Deployments at spec=0 & status=0, idle ≥ threshold | Knative `Service` only | + + Idle cleanup does **not** use object age. A release created months ago is only removed after a **continuous** idle window. + ''' + include platformEngineer + include cleanerController.manager.conditionalTTLReconciler + include cleanerController.manager.idleKnativeReconciler + include kubernetes.conditionalTTLCR + include kubernetes.arbitraryTarget + include kubernetes.helmRelease + include kubernetes.knativeService + include kubernetes.knativeDeployment + include cloudEventsSink + } + + view conditionalTTLComponents { + title 'ConditionalTTL — reconcile flow' + include cleanerController.manager.conditionalTTLReconciler + include cleanerController.manager.conditionalTTLReconciler.reconcileLoop + include cleanerController.manager.conditionalTTLReconciler.celEvaluator + include cleanerController.manager.conditionalTTLReconciler.targetFinalizer + include cleanerController.manager.conditionalTTLReconciler.releaseFinalizer + include cleanerController.manager.conditionalTTLReconciler.cloudEventFinalizer + include kubernetes.conditionalTTLCR + include kubernetes.arbitraryTarget + include kubernetes.helmRelease + include cloudEventsSink + } + + view idleCleanupComponents { + title 'Idle Knative cleanup — reconcile flow' + description ''' + **Candidate** when ALL hold: + - `autoscaling.knative.dev/min-scale: "0"` + - no `cleaner.vtex.io/exclude: "true"` + - at least one Deployment exists + - every Deployment has `spec.replicas == 0` **and** `status.replicas == 0` + + **Not a candidate** during cold start (`spec.replicas > 0`, `status.replicas == 0`). + ''' + include tenantTraffic + include cleanerController.manager.idleKnativeReconciler + include cleanerController.manager.idleKnativeReconciler.idleDetector + include cleanerController.manager.idleKnativeReconciler.idleAnnotator + include cleanerController.manager.idleKnativeReconciler.idleDeleter + include kubernetes.knativeService + include kubernetes.knativeDeployment + } + + dynamic view idleTimerReset { + title 'Idle timer — scale-up resets the 12h clock' + description ''' + Example with `IDLE_KNATIVE_CLEANUP_THRESHOLD=12h`: + + 1. Service scales to zero → `idle-since` stamped at T0 + 2. At T0+3h traffic arrives → spec scales to 1 → `idle-since` **cleared** + 3. Later scales back to zero → **new** `idle-since` at T1 + 4. Deletion only after T1+12h of **continuous** idle + + The 3 hours at zero before the scale-up do **not** count toward deletion. + ''' + variant sequence + + tenantTraffic -> kubernetes.knativeService 'request at T0+3h' + kubernetes.knativeService -> kubernetes.knativeDeployment 'autoscaler sets spec.replicas=1' + kubernetes.knativeDeployment -> cleanerController.manager.idleKnativeReconciler.idleDetector 'deployment watch' + cleanerController.manager.idleKnativeReconciler.idleDetector -> cleanerController.manager.idleKnativeReconciler.idleAnnotator 'not idle (spec>0)' + cleanerController.manager.idleKnativeReconciler.idleAnnotator -> kubernetes.knativeService 'clear idle-since' + + kubernetes.knativeDeployment -> cleanerController.manager.idleKnativeReconciler.idleDetector 'all Deployments spec=0 status=0' + cleanerController.manager.idleKnativeReconciler.idleDetector -> cleanerController.manager.idleKnativeReconciler.idleAnnotator 'candidate' + cleanerController.manager.idleKnativeReconciler.idleAnnotator -> kubernetes.knativeService 'stamp idle-since (new T1)' + + cleanerController.manager.idleKnativeReconciler.idleAnnotator -> cleanerController.manager.idleKnativeReconciler.idleDeleter 'after threshold from T1' + cleanerController.manager.idleKnativeReconciler.idleDeleter -> kubernetes.knativeService 'delete Service' + } + + dynamic view idleContinuousDeletion { + title 'Idle cleanup — continuous 12h at zero deletes' + variant sequence + + kubernetes.knativeDeployment -> cleanerController.manager.idleKnativeReconciler.idleDetector 'spec=0, status=0' + cleanerController.manager.idleKnativeReconciler.idleDetector -> cleanerController.manager.idleKnativeReconciler.idleAnnotator 'mark idle' + cleanerController.manager.idleKnativeReconciler.idleAnnotator -> kubernetes.knativeService 'idle-since = now' + + cleanerController.manager.idleKnativeReconciler.idleAnnotator -> cleanerController.manager.idleKnativeReconciler.idleDeleter 'requeue after threshold' + cleanerController.manager.idleKnativeReconciler.idleDeleter -> kubernetes.knativeService 'still idle?' + cleanerController.manager.idleKnativeReconciler.idleDeleter -> kubernetes.knativeService 'delete Service' + } +}