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
2 changes: 2 additions & 0 deletions create/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-"`
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions create/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 4 additions & 48 deletions create/serviceconnection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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-"`
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion create/serviceconnection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions get/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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})
Expand Down
56 changes: 56 additions & 0 deletions internal/application/reference.go
Original file line number Diff line number Diff line change
@@ -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)
}
67 changes: 67 additions & 0 deletions internal/application/service.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
gajicdev marked this conversation as resolved.
// 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
Comment thread
gajicdev marked this conversation as resolved.
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
}
Loading
Loading