Skip to content

Commit

Permalink
feat: implement resource self-destroy controller
Browse files Browse the repository at this point in the history
For resources not owned by any controller allow using the controller
that will destroy resources which have no finalizers.

Signed-off-by: Artem Chernyshev <artem.chernyshev@talos-systems.com>
  • Loading branch information
Unix4ever committed Feb 29, 2024
1 parent 1c31c46 commit 1418988
Show file tree
Hide file tree
Showing 3 changed files with 239 additions and 1 deletion.
2 changes: 1 addition & 1 deletion pkg/controller/generic/cleanup/cleanup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func TestRemoveWithOutputs(t *testing.T) {
c.res.Metadata().Labels().Set(l.key, l.value)
}

require.NoError(t, st.Create(ctx, c.res), state.WithCreateOwner("user-owner"))
require.NoError(t, st.Create(ctx, c.res))
}

rtestutils.AssertResources(ctx, t, st, []resource.ID{"1", "2", "3"}, func(r *A, assert *assert.Assertions) {
Expand Down
98 changes: 98 additions & 0 deletions pkg/controller/generic/destroy/destroy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

// Package destroy provides a generic implementation of controller which cleans up tearing down resources without finalizers.
package destroy

import (
"context"
"fmt"

"github.com/siderolabs/gen/optional"
"go.uber.org/zap"

"github.com/cosi-project/runtime/pkg/controller"
"github.com/cosi-project/runtime/pkg/controller/generic"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/cosi-project/runtime/pkg/state"
)

// Controller provides a generic implementation of a QController which destroys tearing down resources without finalizers.
type Controller[Input generic.ResourceWithRD] struct {
generic.NamedController
concurrency optional.Optional[uint]
}

// NewController creates a new destroy Controller.
func NewController[Input generic.ResourceWithRD](concurrency optional.Optional[uint]) *Controller[Input] {
var input Input

name := fmt.Sprintf("Destroy[%s]", input.ResourceDefinition().Type)

return &Controller[Input]{
concurrency: concurrency,
NamedController: generic.NamedController{
ControllerName: name,
},
}
}

// Settings implements controller.QController interface.
func (ctrl *Controller[Input]) Settings() controller.QSettings {
var input Input

return controller.QSettings{
Inputs: []controller.Input{
{
Namespace: input.ResourceDefinition().DefaultNamespace,
Type: input.ResourceDefinition().Type,
Kind: controller.InputQPrimary,
},
},
Outputs: []controller.Output{
{
Type: input.ResourceDefinition().Type,
Kind: controller.OutputShared,
},
},
Concurrency: ctrl.concurrency,
}
}

// Reconcile implements controller.QController interface.
func (ctrl *Controller[Input]) Reconcile(ctx context.Context, logger *zap.Logger, r controller.QRuntime, ptr resource.Pointer) error {
in, err := safe.ReaderGet[Input](ctx, r, ptr)
if err != nil {
if state.IsNotFoundError(err) {
return nil
}

return fmt.Errorf("error reading input resource: %w", err)
}

// only handle tearing down resources
if in.Metadata().Phase() != resource.PhaseTearingDown {
return nil
}

// only destroy resources without owner
if in.Metadata().Owner() != "" {
return nil
}

// do not do anything while the resource has any finalizers
if !in.Metadata().Finalizers().Empty() {
return nil
}

logger.Info("destroy the resource without finalizers")

return r.Destroy(ctx, in.Metadata(), controller.WithOwner(""))
}

// MapInput implements controller.QController interface.
func (ctrl *Controller[Input]) MapInput(context.Context, *zap.Logger, controller.QRuntime, resource.Pointer) ([]resource.Pointer, error) {
return nil, nil
}
140 changes: 140 additions & 0 deletions pkg/controller/generic/destroy/destroy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package destroy_test

import (
"context"
"errors"
"testing"
"time"

"github.com/siderolabs/gen/optional"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"

"github.com/cosi-project/runtime/pkg/controller/generic/destroy"
"github.com/cosi-project/runtime/pkg/controller/runtime"
"github.com/cosi-project/runtime/pkg/future"
"github.com/cosi-project/runtime/pkg/logging"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/resource/meta"
"github.com/cosi-project/runtime/pkg/resource/rtestutils"
"github.com/cosi-project/runtime/pkg/resource/typed"
"github.com/cosi-project/runtime/pkg/state"
"github.com/cosi-project/runtime/pkg/state/impl/inmem"
"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
)

// ANamespaceName is the namespace of A resource.
const ANamespaceName = resource.Namespace("ns-a")

// AType is the type of A.
const AType = resource.Type("A.test.cosi.dev")

// A is a test resource.
type A = typed.Resource[ASpec, AE]

// NewA initializes a A resource.
func NewA(id resource.ID) *A {
return typed.NewResource[ASpec, AE](
resource.NewMetadata(ANamespaceName, AType, id, resource.VersionUndefined),
ASpec{},
)
}

// AE provides auxiliary methods for A.
type AE struct{}

// ResourceDefinition implements core.ResourceDefinitionProvider interface.
func (AE) ResourceDefinition() meta.ResourceDefinitionSpec {
return meta.ResourceDefinitionSpec{
Type: AType,
DefaultNamespace: ANamespaceName,
}
}

// ASpec provides A definition.
type ASpec struct{}

// DeepCopy generates a deep copy of NamespaceSpec.
func (a ASpec) DeepCopy() ASpec {
return a
}

func runTest(t *testing.T, f func(ctx context.Context, t *testing.T, st state.State, rt *runtime.Runtime)) {
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())

st := state.WrapCore(namespaced.NewState(inmem.Build))

logger := logging.DefaultLogger()

rt, err := runtime.NewRuntime(st, logger)
require.NoError(t, err)

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

ctx, errCh := future.GoContext(ctx, rt.Run)

t.Cleanup(func() {
err, ok := <-errCh
if !ok {
t.Fatal("runtime exited unexpectedly")
}

if err != nil && !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
})

f(ctx, t, st, rt)
}

func TestFlow(t *testing.T) {
runTest(t, func(ctx context.Context, t *testing.T, st state.State, rt *runtime.Runtime) {
ctrl := destroy.NewController[*A](optional.Some(uint(1)))

require.NoError(t, rt.RegisterQController(ctrl))

a := NewA("1")

require.NoError(t, st.Create(ctx, a))

_, err := st.Teardown(ctx, a.Metadata())
require.NoError(t, err)

rtestutils.AssertNoResource[*A](ctx, t, st, "1")

// should remain until we remove finalizers

a = NewA("1")

a.Metadata().Finalizers().Add("something")

require.NoError(t, st.Create(ctx, a))

_, err = st.Teardown(ctx, a.Metadata())
require.NoError(t, err)

rtestutils.AssertResource[*A](ctx, t, st, "1", func(*A, *assert.Assertions) {})

require.NoError(t, st.RemoveFinalizer(ctx, a.Metadata(), "something"))

rtestutils.AssertNoResource[*A](ctx, t, st, "1")

// owned resources are not touched
a = NewA("2")

require.NoError(t, st.Create(ctx, a, state.WithCreateOwner("pwned")))

_, err = st.Teardown(ctx, a.Metadata(), state.WithTeardownOwner("pwned"))
require.NoError(t, err)

time.Sleep(time.Second)

rtestutils.AssertResource[*A](ctx, t, st, "2", func(*A, *assert.Assertions) {})
})
}

0 comments on commit 1418988

Please sign in to comment.