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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/resource"
"github.com/lucasepe/codename"
meta "github.com/ninech/apis/meta/v1alpha1"
storage "github.com/ninech/apis/storage/v1alpha1"
"github.com/ninech/nctl/api"
"github.com/ninech/nctl/internal/format"
Expand Down Expand Up @@ -143,6 +144,42 @@ func (c *creator) createResource(ctx context.Context) error {
return nil
}

// createResourceInLocation creates the resource and retries once in another
// location if the API server rejects the one it has. requested is the location
// the user asked for, setLocation applies the fallback.
func (c *creator) createResourceInLocation(
ctx context.Context,
requested meta.LocationName,
setLocation func(meta.LocationName),
) error {
err := c.createResource(ctx)
if err == nil {
return nil
}

// the user picked the location and it cannot be changed afterwards, so
// never create the resource somewhere else.
if requested != "" {
return err
}

// the API server returns them sorted, so the fallback is stable.
locations := availableLocations(err)
if len(locations) == 0 {
return err
}
fallback := locations[0]

c.Warningf(
"the default location does not currently accept new %s resources, creating in %q instead. "+
"The location cannot be changed later, pass --location to choose a different one.",
c.kind, fallback,
)
setLocation(fallback)

return c.createResource(ctx)
}

func (c *creator) wait(ctx context.Context, stages ...waitStage) error {
for _, stage := range stages {
if stage.afterWait != nil {
Expand Down
39 changes: 39 additions & 0 deletions create/location.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package create

import (
"errors"
"strings"

meta "github.com/ninech/apis/meta/v1alpha1"

apierrors "k8s.io/apimachinery/pkg/api/errors"
)

// availableLocations returns the locations the API server reported as accepting
// new resources after it denied a create. It returns nil for any other error.
func availableLocations(err error) []meta.LocationName {
var status apierrors.APIStatus
if !errors.As(err, &status) {
return nil
}

details := status.Status().Details
if details == nil {
return nil
}

for _, cause := range details.Causes {
if cause.Type != meta.CauseTypeLocationRestricted {
continue
}

var locations []meta.LocationName
for name := range strings.FieldsSeq(cause.Message) {
locations = append(locations, meta.LocationName(name))
}

return locations
}

return nil
}
207 changes: 207 additions & 0 deletions create/location_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package create

import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"

"github.com/google/go-cmp/cmp"
meta "github.com/ninech/apis/meta/v1alpha1"
storage "github.com/ninech/apis/storage/v1alpha1"
"github.com/ninech/nctl/api"
"github.com/ninech/nctl/internal/test"

apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)

// deniedLocation builds the error the API server returns when it denies a
// create because of its location.
func deniedLocation(available []string) *apierrors.StatusError {
err := apierrors.NewInvalid(
schema.GroupKind{Group: "storage.nine.ch", Kind: "MySQL"},
"test",
nil,
)

err.ErrStatus.Details.Causes = []metav1.StatusCause{
{
Message: fmt.Sprintf("resource in location not allowed, available locations: %v", available),
Field: "field validation error",
},
{
// spelled out on purpose. Using meta.CauseTypeLocationRestricted
// here would make the test pass whatever that constant is set to.
Type: "LocationRestricted",
Field: "spec.forProvider.location",
Message: strings.Join(available, " "),
},
}

return err
}

// deniedLocationWithoutCause builds the same denial as an API server that does
// not report the available locations as a status cause yet.
func deniedLocationWithoutCause(available []string) *apierrors.StatusError {
err := deniedLocation(available)
err.ErrStatus.Details.Causes = err.ErrStatus.Details.Causes[:1]

return err
}

func TestAvailableLocations(t *testing.T) {
t.Parallel()

tests := []struct {
name string
err error
want []meta.LocationName
}{
{
name: "nil error",
err: nil,
},
{
name: "unrelated error",
err: errors.New("connection refused"),
},
{
name: "unrelated api error",
err: apierrors.NewAlreadyExists(
schema.GroupResource{Group: "storage.nine.ch", Resource: "mysqls"}, "test",
),
},
{
name: "denial with status cause",
err: deniedLocation([]string{"nine-cz42", "nine-es34"}),
want: []meta.LocationName{meta.LocationNineCZ42, meta.LocationNineES34},
},
{
name: "denial from a server without the status cause",
err: deniedLocationWithoutCause([]string{"nine-cz42", "nine-es34"}),
},
{
name: "single location",
err: deniedLocation([]string{"nine-es34"}),
want: []meta.LocationName{meta.LocationNineES34},
},
{
name: "no location available",
err: deniedLocation([]string{}),
},
{
name: "wrapped denial",
err: fmt.Errorf("unable to create MySQL %q: %w", "test", deniedLocation([]string{"nine-cz42"})),
want: []meta.LocationName{meta.LocationNineCZ42},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

if diff := cmp.Diff(tt.want, availableLocations(tt.err)); diff != "" {
t.Errorf("availableLocations() mismatch (-want +got):\n%s", diff)
}
})
}
}

// TestCreateLocationFallback checks the full create path, including that the
// retried resource is the one that ends up stored.
func TestCreateLocationFallback(t *testing.T) {
t.Parallel()

tests := []struct {
name string
location meta.LocationName
// denials is the number of creates denied before one is allowed
// through.
denials int
// available are the locations the denial reports, defaulting to two
// when nil.
available []string
want meta.LocationName
wantErr bool
}{
{
name: "no location requested falls back",
denials: 1,
want: meta.LocationNineCZ42,
},
{
name: "denial naming no location is not retried",
denials: 1,
available: []string{},
wantErr: true,
},
{
name: "requested location is not overridden",
location: meta.LocationNineCZ41,
denials: 1,
wantErr: true,
},
{
name: "requested location that is allowed is kept",
location: meta.LocationNineES34,
want: meta.LocationNineES34,
},
{
name: "fallback is only retried once",
denials: 2,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

available := tt.available
if available == nil {
available = []string{"nine-cz42", "nine-es34"}
}

denied := 0
cmd := mySQLCmd{Location: tt.location}
cmd.Name = "test-mysql"
cmd.Wait = false
cmd.WaitTimeout = time.Second

apiClient := test.SetupClient(t, test.WithInterceptorFuncs(interceptor.Funcs{
Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
if denied < tt.denials {
denied++
return deniedLocation(available)
}
return c.Create(ctx, obj, opts...)
},
}))

err := cmd.Run(t.Context(), apiClient)
if (err != nil) != tt.wantErr {
t.Fatalf("mySQLCmd.Run() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}

created := &storage.MySQL{
ObjectMeta: metav1.ObjectMeta{Name: cmd.Name, Namespace: apiClient.Project},
}
if err := apiClient.Get(t.Context(), api.ObjectName(created), created); err != nil {
t.Fatalf("expected mysql to exist, got: %s", err)
}
if got := created.Spec.ForProvider.Location; got != tt.want {
t.Errorf("location = %q, want %q", got, tt.want)
}
})
}
}
4 changes: 3 additions & 1 deletion create/mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error {
ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout)
defer cancel()

if err := c.createResource(ctx); err != nil {
if err := c.createResourceInLocation(ctx, cmd.Location, func(location meta.LocationName) {
mysql.Spec.ForProvider.Location = location
}); err != nil {
return err
}

Expand Down
4 changes: 3 additions & 1 deletion create/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error {
ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout)
defer cancel()

if err := c.createResource(ctx); err != nil {
if err := c.createResourceInLocation(ctx, cmd.Location, func(location meta.LocationName) {
postgres.Spec.ForProvider.Location = location
}); err != nil {
return err
}

Expand Down
13 changes: 12 additions & 1 deletion get/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,31 @@ func printItems(items []*unstructured.Unstructured, get Cmd, header bool) error
get.AllProjects = true

if header {
get.writeHeader("NAME", "KIND", "GROUP")
get.writeHeader("NAME", "KIND", "GROUP", "LOCATION")
}
for _, item := range items {
get.writeTabRow(
item.GetNamespace(),
item.GetName(),
item.GroupVersionKind().Kind,
item.GroupVersionKind().Group,
location(item),
)
}

return get.tabWriter.Flush()
}

// location returns the location of item, for ones that have one.
func location(item *unstructured.Unstructured) string {
loc, found, err := unstructured.NestedString(item.Object, "spec", "forProvider", "location")
if err != nil || !found || loc == "" {
return noneText
Comment thread
thde marked this conversation as resolved.
}

return loc
}

func filteredListTypes(s *runtime.Scheme, kinds []string) ([]schema.GroupVersionKind, error) {
result := []schema.GroupVersionKind{}
lists := nineListTypes(s)
Expand Down
Loading
Loading