From 8ab93becb228908f029e90f42a4c0114b072a8c9 Mon Sep 17 00:00:00 2001 From: Davor Date: Tue, 21 Apr 2026 11:32:53 +0200 Subject: [PATCH] add service linking for deploio apps --- create/application.go | 2 + create/application_test.go | 34 +++++ create/serviceconnection.go | 52 +------- create/serviceconnection_test.go | 3 +- get/application.go | 15 ++- internal/application/reference.go | 56 ++++++++ internal/application/service.go | 67 ++++++++++ internal/application/service_test.go | 192 +++++++++++++++++++++++++++ update/application.go | 9 ++ update/application_test.go | 57 ++++++++ 10 files changed, 436 insertions(+), 51 deletions(-) create mode 100644 internal/application/reference.go create mode 100644 internal/application/service.go create mode 100644 internal/application/service_test.go diff --git a/create/application.go b/create/application.go index 4974218a..a207a647 100644 --- a/create/application.go +++ b/create/application.go @@ -47,6 +47,7 @@ type applicationCmd struct { SensitiveEnv map[string]string `help:"Sensitive environment variables which are passed to the application at runtime."` BuildEnv map[string]string `help:"Environment variables which are passed to the application build process."` SensitiveBuildEnv map[string]string `help:"Sensitive environment variables which are passed to the application build process."` + Service application.ServiceMap `help:"Service reference in the form name=kind/target-name. Credentials will be automatically injected as environment variables."` DeployJob deployJob `embed:"" prefix:"deploy-job-"` WorkerJob workerJob `embed:"" prefix:"worker-job-"` ScheduledJob scheduledJob `embed:"" prefix:"scheduled-job-"` @@ -396,6 +397,7 @@ func (cmd *applicationCmd) newApplication(project string) *apps.Application { Hosts: cmd.Hosts, Config: cmd.config(), BuildEnv: combineEnvVars(cmd.BuildEnv, cmd.SensitiveBuildEnv), + Services: application.ServicesFromMap(cmd.Service, project), DockerfileBuild: apps.DockerfileBuild{ Enabled: cmd.DockerfileBuild.Enabled, DockerfilePath: cmd.DockerfileBuild.Path, diff --git a/create/application_test.go b/create/application_test.go index 5e52bc75..4e06b874 100644 --- a/create/application_test.go +++ b/create/application_test.go @@ -530,6 +530,40 @@ func TestCreateApplication(t *testing.T) { is.Empty(app.Spec.ForProvider.BuildpackStack) }, }, + "with services": { + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Wait: false, + Name: "with-services", + }, + Git: gitConfig{ + URL: "https://github.com/ninech/doesnotexist.git", + Revision: "main", + }, + Service: func() application.ServiceMap { + m := application.ServiceMap{} + cache := application.TypedReference{} + cache.UnmarshalText([]byte("keyvaluestore/my-kvs")) + m["cache"] = cache + db := application.TypedReference{} + db.UnmarshalText([]byte("mysql/my-db")) + m["db"] = db + return m + }(), + SkipRepoAccessCheck: true, + }, + checkApp: func(t *testing.T, cmd applicationCmd, app *apps.Application) { + is := require.New(t) + is.Len(app.Spec.ForProvider.Services, 2) + // sorted by name + is.Equal("cache", app.Spec.ForProvider.Services[0].Name) + is.Equal("my-kvs", app.Spec.ForProvider.Services[0].Target.Name) + is.Equal("KeyValueStore", app.Spec.ForProvider.Services[0].Target.Kind) + is.Equal("db", app.Spec.ForProvider.Services[1].Name) + is.Equal("my-db", app.Spec.ForProvider.Services[1].Target.Name) + is.Equal("MySQL", app.Spec.ForProvider.Services[1].Target.Kind) + }, + }, } for name, tc := range cases { diff --git a/create/serviceconnection.go b/create/serviceconnection.go index b6460f89..67c65a0e 100644 --- a/create/serviceconnection.go +++ b/create/serviceconnection.go @@ -8,7 +8,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/alecthomas/kong" @@ -18,7 +17,7 @@ import ( networking "github.com/ninech/apis/networking/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/cli" + "github.com/ninech/nctl/internal/application" ) // These might be replaced to fetch compatible resources from the schema. @@ -31,8 +30,8 @@ var ( type serviceConnectionCmd struct { resourceCmd - Source TypedReference `placeholder:"kind/name" help:"Source of the connection in the form kind/name. Allowed source kinds are: ${allowed_sources}." required:""` - Destination TypedReference `placeholder:"kind/name" help:"Destination of the connection in the form kind/name. Must be in the same project as the service connection. Allowed destination kinds are: ${allowed_destinations}." required:""` + Source application.TypedReference `placeholder:"kind/name" help:"Source of the connection in the form kind/name. Allowed source kinds are: ${allowed_sources}." required:""` + Destination application.TypedReference `placeholder:"kind/name" help:"Destination of the connection in the form kind/name. Must be in the same project as the service connection. Allowed destination kinds are: ${allowed_destinations}." required:""` SourceNamespace string `help:"Source namespace of the connection. Defaults to current project."` KubernetesClusterOptions KubernetesClusterOptions `embed:"" prefix:"source-"` } @@ -86,29 +85,6 @@ func (ls *LabelSelector) UnmarshalText(text []byte) error { return nil } -// TypedReference is a reference to a resource with a specific type. -type TypedReference struct { - meta.TypedReference -} - -// UnmarshalText parses a typed reference from a string. -func (r *TypedReference) UnmarshalText(text []byte) error { - s := strings.TrimSpace(string(text)) - kind, name, found := strings.Cut(s, "/") - if !found || kind == "" || name == "" { - return fmt.Errorf("unmarshal error: expected kind/name, got %q", text) - } - - gvk, err := groupVersionKindFromKind(kind) - if err != nil { - return fmt.Errorf("unmarshal error: %w", err) - } - - r.Name = name - r.GroupKind = metav1.GroupKind(gvk.GroupKind()) - - return nil -} func (cmd *serviceConnectionCmd) Run(ctx context.Context, client *api.Client) error { sc, err := cmd.newServiceConnection(client.Project) @@ -157,7 +133,7 @@ func (cmd *serviceConnectionCmd) Run(ctx context.Context, client *api.Client) er } func resourceExists(ctx context.Context, key meta.TypedReference, kube client.Reader) (bool, error) { - gvk, err := groupVersionKindFromKind(key.Kind) + gvk, err := application.GroupVersionKindFromKind(key.Kind) if err != nil { return false, err } @@ -205,26 +181,6 @@ func (cmd *serviceConnectionCmd) newServiceConnection(namespace string) (*networ return sc, nil } -func groupVersionKindFromKind(kind string) (schema.GroupVersionKind, error) { - scheme, err := api.NewScheme() - if err != nil { - return schema.GroupVersionKind{}, fmt.Errorf("error creating scheme: %w", err) - } - - for gvk := range scheme.AllKnownTypes() { - if strings.EqualFold(kind, gvk.Kind) { - return gvk, nil - } - } - - return schema.GroupVersionKind{}, cli.ErrorWithContext(fmt.Errorf("kind %q is invalid", kind)). - WithExitCode(cli.ExitUsageError). - WithSuggestions( - "Valid source kinds: "+strings.Join(allowedSources, ", "), - "Valid destination kinds: "+strings.Join(allowedDestinations, ", "), - ) -} - // ServiceConnectionKongVars returns all variables which are used in the ServiceConnection // create command func ServiceConnectionKongVars() kong.Vars { diff --git a/create/serviceconnection_test.go b/create/serviceconnection_test.go index 60b94b88..033b9746 100644 --- a/create/serviceconnection_test.go +++ b/create/serviceconnection_test.go @@ -10,6 +10,7 @@ import ( networking "github.com/ninech/apis/networking/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/application" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -309,7 +310,7 @@ func TestTypedReference_UnmarshalText(t *testing.T) { t.Parallel() is := require.New(t) - r := &TypedReference{} + r := &application.TypedReference{} err := r.UnmarshalText([]byte(tt.arg)) if err == nil && !tt.wantErr { is.Equal(tt.want, r.TypedReference) diff --git a/get/application.go b/get/application.go index 36be157b..17cd41eb 100644 --- a/get/application.go +++ b/get/application.go @@ -95,7 +95,7 @@ func (cmd *applicationsCmd) Help() string { func printApplication(apps []apps.Application, out *output, header bool) error { if header { - out.writeHeader("NAME", "REPLICAS", "WORKERJOBS", "SCHEDULEDJOBS", "HOSTS", "UNVERIFIEDHOSTS") + out.writeHeader("NAME", "REPLICAS", "WORKERJOBS", "SCHEDULEDJOBS", "SERVICES", "HOSTS", "UNVERIFIEDHOSTS") } for _, app := range apps { @@ -108,12 +108,23 @@ func printApplication(apps []apps.Application, out *output, header bool) error { workerJobs := fmt.Sprintf("%d", len(app.Status.AtProvider.WorkerJobs)) scheduledJobs := fmt.Sprintf("%d", len(app.Status.AtProvider.ScheduledJobs)) - out.writeTabRow(app.Namespace, app.Name, fmt.Sprintf("%d", replicas), workerJobs, scheduledJobs, join(verifiedHosts), join(unverifiedHosts)) + out.writeTabRow(app.Namespace, app.Name, fmt.Sprintf("%d", replicas), workerJobs, scheduledJobs, formatServices(app.Spec.ForProvider.Services), join(verifiedHosts), join(unverifiedHosts)) } return out.tabWriter.Flush() } +func formatServices(services apps.NamedServiceTargetList) string { + if len(services) == 0 { + return noneText + } + names := make([]string, 0, len(services)) + for _, s := range services { + names = append(names, fmt.Sprintf("%s=%s/%s", s.Name, strings.ToLower(s.Target.Kind), s.Target.Name)) + } + return strings.Join(names, ",") +} + func printCredentials(creds []appCredentials, out *output) error { if out.Format == yamlOut { return format.PrettyPrintObjects(creds, format.PrintOpts{Out: &out.Writer}) diff --git a/internal/application/reference.go b/internal/application/reference.go new file mode 100644 index 00000000..6dfa13a9 --- /dev/null +++ b/internal/application/reference.go @@ -0,0 +1,56 @@ +package application + +import ( + "fmt" + "strings" + + meta "github.com/ninech/apis/meta/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/cli" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// TypedReference is a reference to a resource with a specific type. +// It implements encoding.TextUnmarshaler to parse "kind/name" strings, +// which allows it to be used directly as a Kong flag type. +type TypedReference struct { + meta.TypedReference +} + +// UnmarshalText parses a typed reference from a string in "kind/name" format. +func (r *TypedReference) UnmarshalText(text []byte) error { + s := strings.TrimSpace(string(text)) + kind, name, found := strings.Cut(s, "/") + if !found || kind == "" || name == "" { + return fmt.Errorf("unmarshal error: expected kind/name, got %q", text) + } + + gvk, err := GroupVersionKindFromKind(kind) + if err != nil { + return fmt.Errorf("unmarshal error: %w", err) + } + + r.Name = name + r.GroupKind = metav1.GroupKind(gvk.GroupKind()) + + return nil +} + +// GroupVersionKindFromKind resolves a case-insensitive kind string to a +// schema.GroupVersionKind using the registered scheme. +func GroupVersionKindFromKind(kind string) (schema.GroupVersionKind, error) { + scheme, err := api.NewScheme() + if err != nil { + return schema.GroupVersionKind{}, fmt.Errorf("error creating scheme: %w", err) + } + + for gvk := range scheme.AllKnownTypes() { + if strings.EqualFold(kind, gvk.Kind) { + return gvk, nil + } + } + + return schema.GroupVersionKind{}, cli.ErrorWithContext(fmt.Errorf("kind %q is invalid", kind)). + WithExitCode(cli.ExitUsageError) +} diff --git a/internal/application/service.go b/internal/application/service.go new file mode 100644 index 00000000..2ab6380c --- /dev/null +++ b/internal/application/service.go @@ -0,0 +1,67 @@ +package application + +import ( + "cmp" + "slices" + + apps "github.com/ninech/apis/apps/v1alpha1" + "github.com/ninech/nctl/internal/format" +) + +// ServiceMap is a map of service name to typed reference, used as a CLI flag type. +type ServiceMap map[string]TypedReference + +// ServicesFromMap converts a map of name -> TypedReference into a +// NamedServiceTargetList. The namespace is set on each target. +func ServicesFromMap(services ServiceMap, namespace string) apps.NamedServiceTargetList { + if len(services) == 0 { + return nil + } + + result := make(apps.NamedServiceTargetList, 0, len(services)) + for name, ref := range services { + ref.Namespace = namespace + result = append(result, apps.NamedServiceTarget{ + Name: name, + Target: ref.TypedReference, + }) + } + + slices.SortFunc(result, func(a, b apps.NamedServiceTarget) int { + return cmp.Compare(a.Name, b.Name) + }) + + return result +} + +// UpdateServices merges toAdd into existing services (upsert by name) and +// removes services listed in toDelete. Warnings are emitted for delete-not-found cases. +func UpdateServices(existing apps.NamedServiceTargetList, toAdd apps.NamedServiceTargetList, toDelete []string, w format.Writer) apps.NamedServiceTargetList { + // upsert: update existing or append new + for _, add := range toAdd { + found := false + for i := range existing { + if existing[i].Name == add.Name { + existing[i].Target = add.Target + found = true + break + } + } + if !found { + existing = append(existing, add) + } + } + + // delete + for _, name := range toDelete { + before := len(existing) + existing = slices.DeleteFunc(existing, func(s apps.NamedServiceTarget) bool { + return s.Name == name + }) + if len(existing) == before { + w.Warningf("did not find a service with the name %q", name) + } + } + + return existing +} diff --git a/internal/application/service_test.go b/internal/application/service_test.go new file mode 100644 index 00000000..bdf83a62 --- /dev/null +++ b/internal/application/service_test.go @@ -0,0 +1,192 @@ +package application + +import ( + "bytes" + "strings" + "testing" + + apps "github.com/ninech/apis/apps/v1alpha1" + meta "github.com/ninech/apis/meta/v1alpha1" + "github.com/ninech/nctl/internal/format" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestServicesFromMap(t *testing.T) { + t.Parallel() + + kvsRef := TypedReference{} + require.NoError(t, kvsRef.UnmarshalText([]byte("keyvaluestore/my-kvs"))) + + mysqlRef := TypedReference{} + require.NoError(t, mysqlRef.UnmarshalText([]byte("mysql/my-db"))) + + tests := []struct { + name string + services ServiceMap + namespace string + want apps.NamedServiceTargetList + }{ + { + name: "nil map", + services: nil, + want: nil, + }, + { + name: "single service", + services: ServiceMap{"cache": kvsRef}, + namespace: "my-project", + want: apps.NamedServiceTargetList{ + { + Name: "cache", + Target: meta.TypedReference{ + Reference: meta.Reference{Name: "my-kvs", Namespace: "my-project"}, + GroupKind: kvsRef.GroupKind, + }, + }, + }, + }, + { + name: "multiple services sorted", + services: ServiceMap{ + "db": mysqlRef, + "cache": kvsRef, + }, + namespace: "default", + want: apps.NamedServiceTargetList{ + { + Name: "cache", + Target: meta.TypedReference{ + Reference: meta.Reference{Name: "my-kvs", Namespace: "default"}, + GroupKind: kvsRef.GroupKind, + }, + }, + { + Name: "db", + Target: meta.TypedReference{ + Reference: meta.Reference{Name: "my-db", Namespace: "default"}, + GroupKind: mysqlRef.GroupKind, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ServicesFromMap(tt.services, tt.namespace) + require.Equal(t, tt.want, got) + }) + } +} + +func TestTypedReference_UnmarshalText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid keyvaluestore", "keyvaluestore/my-kvs", false}, + {"valid mysql", "mysql/my-db", false}, + {"empty", "", true}, + {"no slash", "keyvaluestore", true}, + {"missing name", "keyvaluestore/", true}, + {"invalid kind", "invalid/my-resource", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &TypedReference{} + err := r.UnmarshalText([]byte(tt.input)) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotEmpty(t, r.Name) + require.NotEmpty(t, r.Kind) + } + }) + } +} + +func TestUpdateServices(t *testing.T) { + t.Parallel() + + kvsTarget := meta.TypedReference{ + Reference: meta.Reference{Name: "my-kvs", Namespace: "default"}, + GroupKind: metav1.GroupKind{Group: "storage.nine.ch", Kind: "KeyValueStore"}, + } + mysqlTarget := meta.TypedReference{ + Reference: meta.Reference{Name: "my-db", Namespace: "default"}, + GroupKind: metav1.GroupKind{Group: "storage.nine.ch", Kind: "MySQL"}, + } + newKvsTarget := meta.TypedReference{ + Reference: meta.Reference{Name: "new-kvs", Namespace: "default"}, + GroupKind: metav1.GroupKind{Group: "storage.nine.ch", Kind: "KeyValueStore"}, + } + + tests := []struct { + name string + existing apps.NamedServiceTargetList + toAdd apps.NamedServiceTargetList + toDelete []string + want apps.NamedServiceTargetList + wantWarn bool + }{ + { + name: "add to empty", + existing: nil, + toAdd: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}}, + want: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}}, + }, + { + name: "add new service", + existing: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}}, + toAdd: apps.NamedServiceTargetList{{Name: "db", Target: mysqlTarget}}, + want: apps.NamedServiceTargetList{ + {Name: "cache", Target: kvsTarget}, + {Name: "db", Target: mysqlTarget}, + }, + }, + { + name: "update existing service", + existing: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}}, + toAdd: apps.NamedServiceTargetList{{Name: "cache", Target: newKvsTarget}}, + want: apps.NamedServiceTargetList{{Name: "cache", Target: newKvsTarget}}, + }, + { + name: "delete service", + existing: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}, {Name: "db", Target: mysqlTarget}}, + toDelete: []string{"cache"}, + want: apps.NamedServiceTargetList{{Name: "db", Target: mysqlTarget}}, + }, + { + name: "delete non-existent warns", + existing: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}}, + toDelete: []string{"nonexistent"}, + want: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}}, + wantWarn: true, + }, + { + name: "add and delete", + existing: apps.NamedServiceTargetList{{Name: "cache", Target: kvsTarget}}, + toAdd: apps.NamedServiceTargetList{{Name: "db", Target: mysqlTarget}}, + toDelete: []string{"cache"}, + want: apps.NamedServiceTargetList{{Name: "db", Target: mysqlTarget}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + buf := &bytes.Buffer{} + w := format.NewWriter(buf) + got := UpdateServices(tt.existing, tt.toAdd, tt.toDelete, w) + require.Equal(t, tt.want, got) + if tt.wantWarn { + require.True(t, strings.Contains(buf.String(), "did not find"), "expected warning but got: %q", buf.String()) + } + }) + } +} diff --git a/update/application.go b/update/application.go index 53f905bb..844ac6b0 100644 --- a/update/application.go +++ b/update/application.go @@ -53,6 +53,8 @@ type applicationCmd struct { ScheduledJob *scheduledJob `embed:"" prefix:"scheduled-job-"` DeleteWorkerJob *string `help:"Delete a worker job by name."` DeleteScheduledJob *string `help:"Delete a scheduled job by name."` + Service application.ServiceMap `help:"Service reference to add/update in the form name=kind/target-name."` + DeleteService []string `help:"Service reference names to remove."` RetryRelease *bool `help:"Retries release for the application." placeholder:"false"` RetryBuild *bool `help:"Retries build for the application if set to true." placeholder:"false"` Pause *bool `help:"Pauses the application if set to true. Stops all costs." placeholder:"false"` @@ -351,6 +353,13 @@ func (cmd *applicationCmd) applyUpdates(app *apps.Application) { app.Spec.ForProvider.DockerfileBuild.BuildContext = *cmd.DockerfileBuild.BuildContext warnIfDockerfileNotEnabled(cmd.Writer, app, "build context") } + + if len(cmd.Service) > 0 || len(cmd.DeleteService) > 0 { + toAdd := application.ServicesFromMap(cmd.Service, app.Namespace) + app.Spec.ForProvider.Services = application.UpdateServices( + app.Spec.ForProvider.Services, toAdd, cmd.DeleteService, cmd.Writer, + ) + } } func triggerTimestamp() string { diff --git a/update/application_test.go b/update/application_test.go index 5e8ebe72..c1dac3a6 100644 --- a/update/application_test.go +++ b/update/application_test.go @@ -8,6 +8,8 @@ import ( "github.com/alecthomas/kong" apps "github.com/ninech/apis/apps/v1alpha1" + meta "github.com/ninech/apis/meta/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api/gitinfo" "github.com/ninech/nctl/create" "github.com/ninech/nctl/internal/application" @@ -687,6 +689,61 @@ func TestApplication(t *testing.T) { is.Equal("main", updated.Spec.ForProvider.Git.Revision) }, }, + "add service": { + orig: existingApp, + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Name: existingApp.Name, + }, + Service: func() application.ServiceMap { + ref := application.TypedReference{} + ref.UnmarshalText([]byte("keyvaluestore/my-kvs")) + return application.ServiceMap{"cache": ref} + }(), + }, + checkApp: func(t *testing.T, cmd applicationCmd, orig, updated *apps.Application) { + is := require.New(t) + is.Len(updated.Spec.ForProvider.Services, 1) + is.Equal("cache", updated.Spec.ForProvider.Services[0].Name) + is.Equal("my-kvs", updated.Spec.ForProvider.Services[0].Target.Name) + is.Equal(storage.KeyValueStoreKind, updated.Spec.ForProvider.Services[0].Target.Kind) + }, + }, + "delete service": { + orig: &apps.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-name", + Namespace: test.DefaultProject, + }, + Spec: apps.ApplicationSpec{ + ForProvider: apps.ApplicationParameters{ + Git: existingApp.Spec.ForProvider.Git, + Config: apps.Config{ + Size: initialSize, + }, + Services: apps.NamedServiceTargetList{ + { + Name: "cache", + Target: meta.TypedReference{ + Reference: meta.Reference{Name: "my-kvs", Namespace: test.DefaultProject}, + GroupKind: metav1.GroupKind{Group: storage.Group, Kind: storage.KeyValueStoreKind}, + }, + }, + }, + }, + }, + }, + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Name: existingApp.Name, + }, + DeleteService: []string{"cache"}, + }, + checkApp: func(t *testing.T, cmd applicationCmd, orig, updated *apps.Application) { + is := require.New(t) + is.Empty(updated.Spec.ForProvider.Services) + }, + }, } for name, tc := range cases {