diff --git a/apply/apply.go b/apply/apply.go index 82ecb4c9..5cb97e86 100644 --- a/apply/apply.go +++ b/apply/apply.go @@ -1,6 +1,8 @@ package apply +import "os" + type Cmd struct { - Filename string `short:"f" predictor:"file"` + Filename *os.File `short:"f" predictor:"file"` FromFile fromFile `cmd:"" default:"1" name:"-f " help:"Apply any resource from a yaml or json file."` } diff --git a/apply/file.go b/apply/file.go index 77b590e6..96438c62 100644 --- a/apply/file.go +++ b/apply/file.go @@ -40,23 +40,19 @@ func Delete() Option { } } -func File(ctx context.Context, client *api.Client, filename string, opts ...Option) error { - if len(filename) == 0 { +func File(ctx context.Context, client *api.Client, file *os.File, opts ...Option) error { + if file == nil { return fmt.Errorf("missing flag -f, --filename=STRING") } + defer file.Close() cfg := &config{} for _, opt := range opts { opt(cfg) } - f, err := os.Open(filename) - if err != nil { - return err - } - obj := &unstructured.Unstructured{} - if err := yaml.NewYAMLOrJSONDecoder(f, 4096).Decode(obj); err != nil { + if err := yaml.NewYAMLOrJSONDecoder(file, 4096).Decode(obj); err != nil { return err } diff --git a/apply/file_test.go b/apply/file_test.go index 3e9a168f..e9874a78 100644 --- a/apply/file_test.go +++ b/apply/file_test.go @@ -118,39 +118,39 @@ func TestFile(t *testing.T) { if _, err := fmt.Fprintf(f, tc.file, name, "value", runtimev1.DeletionOrphan); err != nil { t.Fatal(err) } + // The file is written, but the pointer is at the end. + // Close it to flush content. + require.NoError(t, f.Close()) opts := []Option{} + // For delete and update tests, we first create the resource. + if tc.delete || tc.update { + fileToCreate, err := os.Open(f.Name()) + require.NoError(t, err) + err = File(ctx, apiClient, fileToCreate) // This will close fileToCreate + require.NoError(t, err) + } + if tc.delete { - // we need to ensure that the resource exists before we can delete it - if err := File(ctx, apiClient, f.Name()); err != nil { - t.Fatal(err) - } opts = append(opts, Delete()) } if tc.update { - // we need to ensure that the resource exists before we can update - if err := File(ctx, apiClient, f.Name()); err != nil { - t.Fatal(err) - } - - if err := f.Truncate(0); err != nil { - t.Fatal(err) - } - - if _, err := f.Seek(0, 0); err != nil { - t.Fatal(err) - } - - if _, err := fmt.Fprintf(f, tc.file, name, tc.updatedAnnotation, tc.updatedSpecValue); err != nil { - t.Fatal(err) - } + // Re-create the file to truncate it and write the updated content. + updatedFile, err := os.Create(f.Name()) + require.NoError(t, err) + _, err = fmt.Fprintf(updatedFile, tc.file, name, tc.updatedAnnotation, tc.updatedSpecValue) + require.NoError(t, err) + require.NoError(t, updatedFile.Close()) opts = append(opts, UpdateOnExists()) } - if err := File(ctx, apiClient, f.Name(), opts...); err != nil { + fileToApply, err := os.Open(f.Name()) + require.NoError(t, err) + + if err := File(ctx, apiClient, fileToApply, opts...); err != nil { if tc.expectedErr { return } diff --git a/auth/client_credentials.go b/auth/client_credentials.go index 17320c86..cd891907 100644 --- a/auth/client_credentials.go +++ b/auth/client_credentials.go @@ -17,11 +17,11 @@ import ( ) type API struct { - URL string `help:"The URL of the Nine API" default:"https://nineapis.ch" env:"NCTL_API_URL" hidden:""` + URL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" hidden:""` Token string `help:"Use a static API token instead of using an OIDC login. Requires the --organization to also be set." env:"NCTL_API_TOKEN" hidden:""` ClientID string `help:"Use an API client ID to login. Requires the --organization to also be set." env:"NCTL_API_CLIENT_ID"` ClientSecret string `help:"Use an API client secret to login. Requires the --organization to also be set." env:"NCTL_API_CLIENT_SECRET"` - TokenURL string `help:"Override the default client token URL" hidden:"" default:"${token_url}" env:"NCTL_API_TOKEN_URL"` + TokenURL string `help:"Override the default client token URL." hidden:"" default:"${token_url}" env:"NCTL_API_TOKEN_URL"` } type ClientCredentialsCmd struct { diff --git a/auth/login.go b/auth/login.go index 8fd04d7a..91cb5d51 100644 --- a/auth/login.go +++ b/auth/login.go @@ -27,9 +27,9 @@ const ( type LoginCmd struct { API API `embed:"" prefix:"api-"` - Organization string `help:"The name of your organization to use when providing an API client ID/secret." env:"NCTL_ORGANIZATION"` - IssuerURL string `help:"Issuer URL is the OIDC issuer URL of the API." default:"${issuer_url}" hidden:""` - ClientID string `help:"Client ID is the OIDC client ID of the API." default:"${client_id}" hidden:""` + Organization string `help:"Name of your organization to use when providing an API client ID/secret." env:"NCTL_ORGANIZATION"` + IssuerURL string `help:"OIDC issuer URL of the API." default:"${issuer_url}" hidden:""` + ClientID string `help:"OIDC client ID of the API." default:"${client_id}" hidden:""` ForceInteractiveEnvOverride bool `help:"Used for internal purposes only. Set to true to force interactive environment explicit override. Set to false to fall back to automatic interactivity detection." default:"false" hidden:""` tk api.TokenGetter } diff --git a/auth/logout.go b/auth/logout.go index 2514b691..14c3555a 100644 --- a/auth/logout.go +++ b/auth/logout.go @@ -22,9 +22,9 @@ import ( ) type LogoutCmd struct { - APIURL string `help:"The URL of the Nine API" default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` - IssuerURL string `help:"Issuer URL is the OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` - ClientID string `help:"Client ID is the OIDC client ID of the API." default:"nineapis.ch-f178254"` + APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` + IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` + ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` tk api.TokenGetter } diff --git a/auth/set_org.go b/auth/set_org.go index 090f461f..ff6aacb3 100644 --- a/auth/set_org.go +++ b/auth/set_org.go @@ -12,9 +12,9 @@ import ( type SetOrgCmd struct { Organization string `arg:"" help:"Name of the organization to login to." default:""` - APIURL string `help:"The URL of the Nine API" default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` - IssuerURL string `help:"Issuer URL is the OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` - ClientID string `help:"Client ID is the OIDC client ID of the API." default:"nineapis.ch-f178254"` + APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` + IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` + ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` } func (s *SetOrgCmd) Run(ctx context.Context, client *api.Client) error { diff --git a/auth/whoami.go b/auth/whoami.go index 93bf3bea..6de832bc 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -8,9 +8,9 @@ import ( ) type WhoAmICmd struct { - APIURL string `help:"The URL of the Nine API" default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` - IssuerURL string `help:"Issuer URL is the OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` - ClientID string `help:"Client ID is the OIDC client ID of the API." default:"nineapis.ch-f178254"` + APIURL string `help:"URL of the Nine API." default:"https://nineapis.ch" env:"NCTL_API_URL" name:"api-url"` + IssuerURL string `help:"OIDC issuer URL of the API." default:"https://auth.nine.ch/auth/realms/pub"` + ClientID string `help:"OIDC client ID of the API." default:"nineapis.ch-f178254"` } func (s *WhoAmICmd) Run(ctx context.Context, client *api.Client) error { diff --git a/create/application.go b/create/application.go index fadda414..00059f7b 100644 --- a/create/application.go +++ b/create/application.go @@ -37,29 +37,29 @@ const logPrintTimeout = 10 * time.Second type applicationCmd struct { resourceCmd Git gitConfig `embed:"" prefix:"git-"` - Size *string `help:"Size of the app (defaults to \"${app_default_size}\")." placeholder:"${app_default_size}"` - Port *int32 `help:"Port the app is listening on (defaults to ${app_default_port})." placeholder:"${app_default_port}"` - Replicas *int32 `help:"Amount of replicas of the running app (defaults to ${app_default_replicas})." placeholder:"${app_default_replicas}"` - Hosts []string `help:"Host names where the app can be accessed. If empty, the app will just be accessible on a generated host name on the deploio.app domain."` - BasicAuth *bool `help:"Enable/Disable basic authentication for the app (defaults to ${app_default_basic_auth})." placeholder:"${app_default_basic_auth}"` - Env map[string]string `help:"Environment variables which are passed to the app at runtime."` - SensitiveEnv map[string]string `help:"Sensitive environment variables which are passed to the app at runtime."` - BuildEnv map[string]string `help:"Environment variables which are passed to the app build process."` - SensitiveBuildEnv map[string]string `help:"Sensitive environment variables which are passed to the app build process."` + Size *string `help:"Size of the application (defaults to \"${app_default_size}\")." placeholder:"${app_default_size}"` + Port *int32 `help:"Port the application is listening on (defaults to ${app_default_port})." placeholder:"${app_default_port}"` + Replicas *int32 `help:"Amount of replicas of the running application (defaults to ${app_default_replicas})." placeholder:"${app_default_replicas}"` + Hosts []string `help:"Host names where the application can be accessed. If empty, the application will just be accessible on a generated host name on the deploio.app domain."` + BasicAuth *bool `help:"Enable/Disable basic authentication for the application (defaults to ${app_default_basic_auth})." placeholder:"${app_default_basic_auth}"` + Env map[string]string `help:"Environment variables which are passed to the application at runtime."` + 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."` DeployJob deployJob `embed:"" prefix:"deploy-job-"` WorkerJob workerJob `embed:"" prefix:"worker-job-"` ScheduledJob scheduledJob `embed:"" prefix:"scheduled-job-"` GitInformationServiceURL string `help:"URL of the git information service." default:"https://git-info.deplo.io" env:"GIT_INFORMATION_SERVICE_URL" hidden:""` - SkipRepoAccessCheck bool `help:"Skip the git repository access check" default:"false"` - Debug bool `help:"Enable debug messages" default:"false"` + SkipRepoAccessCheck bool `help:"Skip the git repository access check." default:"false"` + Debug bool `help:"Enable debug messages." default:"false"` Language string `help:"${app_language_help} Possible values: ${enum}" enum:"ruby,php,python,golang,nodejs,static," default:""` DockerfileBuild dockerfileBuild `embed:""` } type gitConfig struct { - URL string `required:"" help:"URL to the Git repository containing the app source. Both HTTPS and SSH formats are supported."` - SubPath string `help:"SubPath is a path in the git repo which contains the app code. If not given, the root directory of the git repo will be used."` - Revision string `default:"main" help:"Revision defines the revision of the source to deploy the app to. This can be a commit, tag or branch."` + URL string `required:"" help:"URL to the Git repository containing the application source. Both HTTPS and SSH formats are supported."` + SubPath string `help:"SubPath is a path in the git repository which contains the application code. If not given, the root directory of the git repository will be used."` + Revision string `default:"main" help:"Revision defines the revision of the source to deploy the application to. This can be a commit, tag or branch."` Username *string `help:"Username to use when authenticating to the git repository over HTTPS." env:"GIT_USERNAME"` Password *string `help:"Password to use when authenticating to the git repository over HTTPS. In case of GitHub or GitLab, this can also be an access token." env:"GIT_PASSWORD"` SSHPrivateKey *string `help:"Private key in PEM format to connect to the git repository via SSH." env:"GIT_SSH_PRIVATE_KEY" xor:"SSH_KEY"` @@ -74,9 +74,9 @@ type deployJob struct { } type workerJob struct { - Command string `help:"Command to execute to start the worker." placeholder:"\"bundle exec sidekiq\""` + Command string `help:"Command to execute to start the worker job." placeholder:"\"bundle exec sidekiq\""` Name string `help:"Name of the worker job to add." placeholder:"worker-1"` - Size *string `help:"Size of the worker (defaults to \"${app_default_size}\")." placeholder:"${app_default_size}"` + Size *string `help:"Size of the worker job (defaults to \"${app_default_size}\")." placeholder:"${app_default_size}"` } type scheduledJob struct { @@ -89,9 +89,9 @@ type scheduledJob struct { } type dockerfileBuild struct { - Enabled bool `name:"dockerfile" help:"${app_dockerfile_enable_help}" default:"false"` - Path string `name:"dockerfile-path" help:"${app_dockerfile_path_help}" default:""` - BuildContext string `name:"dockerfile-build-context" help:"${app_dockerfile_build_context_help}" default:""` + Enabled bool `name:"dockerfile" help:"${app_dockerfile_enable_help}." default:"false"` + Path string `name:"dockerfile-path" help:"${app_dockerfile_path_help}." default:""` + BuildContext string `name:"dockerfile-build-context" help:"${app_dockerfile_build_context_help}." default:""` } func (g gitConfig) sshPrivateKey() (*string, error) { diff --git a/create/bucketuser.go b/create/bucketuser.go index 3034b703..259661c7 100644 --- a/create/bucketuser.go +++ b/create/bucketuser.go @@ -18,7 +18,7 @@ import ( type bucketUserCmd struct { resourceCmd - Location string `placeholder:"${bucketuser_location_default}" help:"Location where the BucketUser instance is created. Available locations are: ${bucketuser_location_options}"` + Location meta.LocationName `placeholder:"${bucketuser_location_default}" help:"Where the BucketUser instance is created. Available locations are: ${bucketuser_location_options}"` } func (cmd *bucketUserCmd) Run(ctx context.Context, client *api.Client) error { @@ -64,7 +64,7 @@ func (cmd *bucketUserCmd) newBucketUser(namespace string) *storage.BucketUser { }, }, ForProvider: storage.BucketUserParameters{ - Location: meta.LocationName(cmd.Location), + Location: cmd.Location, }, }, } diff --git a/create/cloudvm.go b/create/cloudvm.go index 7b45eadd..d3a8ed2b 100644 --- a/create/cloudvm.go +++ b/create/cloudvm.go @@ -3,6 +3,7 @@ package create import ( "context" "fmt" + "io" "os" "strings" @@ -18,18 +19,18 @@ import ( type cloudVMCmd struct { resourceCmd - Location string `default:"nine-es34" help:"Location where the CloudVM instance is created."` - MachineType string `default:"" help:"The machine type defines the sizing for a particular CloudVM."` - Hostname string `default:"" help:"Hostname allows to set the hostname explicitly. If unset, the name of the resource will be used as the hostname. This does not affect the DNS name."` - ReverseDNS string `default:"" help:"Allows to set the reverse DNS of the CloudVM"` - PowerState string `default:"on" help:"Specify the initial power state of the CloudVM. Set to off to create "` - OS string `default:"" help:"OS which should be used to boot the VM. Available options: ${cloudvm_os_flavors}"` - BootDiskSize string `default:"20Gi" help:"Configures the size of the boot disk."` - Disks map[string]string `default:"" help:"Disks specifies which additional disks to mount to the machine."` - PublicKeys []string `default:"" help:"SSH public keys that can be used to connect to the CloudVM as root. The keys are expected to be in SSH format as defined in RFC4253. Immutable after creation."` - PublicKeysFromFiles []string `default:"" predictor:"file" help:"SSH public key files that can be used to connect to the VM as root. The keys are expected to be in SSH format as defined in RFC4253. Immutable after creation."` - CloudConfig string `default:"" help:"CloudConfig allows to pass custom cloud config data (https://cloudinit.readthedocs.io/en/latest/topics/format.html#cloud-config-data) to the cloud VM. If a CloudConfig is passed, the PublicKey parameter is ignored. Immutable after creation."` - CloudConfigFromFile string `default:"" predictor:"file" help:"CloudConfig via file. Has precedence over args. CloudConfig allows to pass custom cloud config data (https://cloudinit.readthedocs.io/en/latest/topics/format.html#cloud-config-data) to the cloud VM. If a CloudConfig is passed, the PublicKey parameter is ignored. Immutable after creation."` + Location meta.LocationName `default:"nine-es34" help:"Where the CloudVM instance is created."` + MachineType string `default:"" help:"Defines the sizing for a particular CloudVM."` + Hostname string `default:"" help:"Configures the hostname explicitly. If unset, the name of the resource will be used as the hostname. This does not affect the DNS name."` + ReverseDNS string `default:"" help:"Configures the reverse DNS of the CloudVM."` + PowerState infrastructure.VirtualMachinePowerState `default:"on" help:"Specify the initial power state of the CloudVM. Set to off to not start the VM after creation."` + OS infrastructure.OperatingSystem `default:"" help:"Operating system to use to boot the VM. Available options: ${cloudvm_os_flavors}"` + BootDiskSize *resource.Quantity `default:"20Gi" help:"Configures the size of the boot disk."` + Disks map[string]resource.Quantity `default:"" help:"Additional disks to mount to the machine."` + PublicKeys []string `default:"" help:"SSH public keys to connect to the CloudVM as root. The keys are expected to be in SSH format as defined in RFC4253. Immutable after creation."` + PublicKeysFromFiles []*os.File `default:"" predictor:"file" help:"SSH public key files to connect to the VM as root. The keys are expected to be in SSH format as defined in RFC4253. Immutable after creation."` + CloudConfig string `default:"" help:"Pass custom cloud config data (https://cloudinit.readthedocs.io/en/latest/topics/format.html#cloud-config-data) to the cloud VM. If a CloudConfig is passed, the PublicKey parameter is ignored. Immutable after creation."` + CloudConfigFromFile *os.File `default:"" predictor:"file" help:"Pass custom cloud config data (https://cloudinit.readthedocs.io/en/latest/topics/format.html#cloud-config-data) from a file. Takes precedence. If a CloudConfig is passed, the PublicKey parameter is ignored. Immutable after creation."` } func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client) error { @@ -84,11 +85,11 @@ func (cmd *cloudVMCmd) newCloudVM(namespace string) (*infrastructure.CloudVirtua }, }, ForProvider: infrastructure.CloudVirtualMachineParameters{ - Location: meta.LocationName(cmd.Location), + Location: cmd.Location, MachineType: infrastructure.NewMachineType(cmd.MachineType), Hostname: cmd.Hostname, - PowerState: infrastructure.VirtualMachinePowerState(cmd.PowerState), - OS: infrastructure.CloudVirtualMachineOS(infrastructure.OperatingSystem(cmd.OS)), + PowerState: cmd.PowerState, + OS: infrastructure.CloudVirtualMachineOS(cmd.OS), PublicKeys: cmd.PublicKeys, CloudConfig: cmd.CloudConfig, ReverseDNS: cmd.ReverseDNS, @@ -100,19 +101,23 @@ func (cmd *cloudVMCmd) newCloudVM(namespace string) (*infrastructure.CloudVirtua cloudVM.Spec.ForProvider.PublicKeys = cmd.PublicKeys var keys []string for _, file := range cmd.PublicKeysFromFiles { - b, err := os.ReadFile(file) + if file == nil { + continue + } + + b, err := io.ReadAll(file) if err != nil { - return nil, fmt.Errorf("error reading cloudconfig file %q: %w", cmd.PublicKeysFromFiles, err) + return nil, fmt.Errorf("error reading public keys file: %w", err) } keys = append(keys, string(b)) } cloudVM.Spec.ForProvider.PublicKeys = keys } - if len(cmd.CloudConfigFromFile) != 0 { - b, err := os.ReadFile(cmd.CloudConfigFromFile) + if cmd.CloudConfigFromFile != nil { + b, err := io.ReadAll(cmd.CloudConfigFromFile) if err != nil { - return nil, fmt.Errorf("error reading cloudconfig file %q: %w", cmd.CloudConfigFromFile, err) + return nil, fmt.Errorf("error reading cloudconfig file: %w", err) } cloudVM.Spec.ForProvider.CloudConfig = string(b) } @@ -120,22 +125,13 @@ func (cmd *cloudVMCmd) newCloudVM(namespace string) (*infrastructure.CloudVirtua if len(cmd.Disks) != 0 { disks := []infrastructure.Disk{} for name, size := range cmd.Disks { - q, err := resource.ParseQuantity(size) - if err != nil { - return nil, fmt.Errorf("error parsing disk size %q: %w", size, err) - } - disks = append(disks, infrastructure.Disk{Name: name, Size: q}) + disks = append(disks, infrastructure.Disk{Name: name, Size: size}) } cloudVM.Spec.ForProvider.Disks = disks } - if len(cmd.BootDiskSize) != 0 { - q, err := resource.ParseQuantity(cmd.BootDiskSize) - if err != nil { - return cloudVM, fmt.Errorf("error parsing disk size %q: %w", cmd.BootDiskSize, err) - } - cloudVM.Spec.ForProvider.BootDisk = &infrastructure.Disk{Name: "root", Size: q} - + if cmd.BootDiskSize != nil { + cloudVM.Spec.ForProvider.BootDisk = &infrastructure.Disk{Name: "root", Size: *cmd.BootDiskSize} } return cloudVM, nil diff --git a/create/cloudvm_test.go b/create/cloudvm_test.go index 299896db..40d13cf2 100644 --- a/create/cloudvm_test.go +++ b/create/cloudvm_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) func TestCloudVM(t *testing.T) { @@ -29,7 +30,7 @@ func TestCloudVM(t *testing.T) { { name: "disks", create: cloudVMCmd{ - Disks: map[string]string{"a": "1Gi"}, + Disks: map[string]resource.Quantity{"a": resource.MustParse("1Gi")}, }, want: infrastructure.CloudVirtualMachineParameters{ Disks: []infrastructure.Disk{ @@ -39,7 +40,7 @@ func TestCloudVM(t *testing.T) { }, { name: "bootDisk", - create: cloudVMCmd{BootDiskSize: "1Gi"}, + create: cloudVMCmd{BootDiskSize: ptr.To(resource.MustParse("1Gi"))}, want: infrastructure.CloudVirtualMachineParameters{ BootDisk: &infrastructure.Disk{ Name: "root", Size: resource.MustParse("1Gi"), diff --git a/create/create.go b/create/create.go index a79ec05d..6ffd16fc 100644 --- a/create/create.go +++ b/create/create.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand" + "os" "time" runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" @@ -20,7 +21,7 @@ import ( ) type Cmd struct { - Filename string `short:"f" help:"Create any resource from a yaml or json file." predictor:"file"` + Filename *os.File `short:"f" help:"Create any resource from a yaml or json file." predictor:"file"` FromFile fromFile `cmd:"" default:"1" name:"-f " help:"Create any resource from a yaml or json file."` VCluster vclusterCmd `cmd:"" group:"infrastructure.nine.ch" name:"vcluster" help:"Create a new vcluster."` APIServiceAccount apiServiceAccountCmd `cmd:"" group:"iam.nine.ch" name:"apiserviceaccount" aliases:"asa" help:"Create a new API Service Account."` @@ -260,6 +261,7 @@ func getName(name string) string { return codename.Generate(rand.New(rand.NewSource(time.Now().UnixNano())), 0) } +// stringSlice converts a slice of string like elements to a slice of strings. func stringSlice[K ~string](elems []K) []string { s := make([]string, 0, len(elems)) for _, elem := range elems { @@ -267,3 +269,12 @@ func stringSlice[K ~string](elems []K) []string { } return s } + +// stringerSlice converts a slice of elements implementing [fmt.Stringer] to a slice of strings. +func stringerSlice[T fmt.Stringer](slice []T) []string { + strings := make([]string, 0, len(slice)) + for _, e := range slice { + strings = append(strings, e.String()) + } + return strings +} diff --git a/create/keyvaluestore.go b/create/keyvaluestore.go index cbc4cb53..252c1c9f 100644 --- a/create/keyvaluestore.go +++ b/create/keyvaluestore.go @@ -2,24 +2,27 @@ package create import ( "context" - "fmt" + "strings" + "github.com/alecthomas/kong" runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" ) type keyValueStoreCmd struct { resourceCmd - Location string `placeholder:"nine-es34" help:"Location where the KeyValueStore instance is created."` - MemorySize string `help:"MemorySize configures KeyValueStore to use a specified amount of memory for the data set." placeholder:"1Gi"` - MaxMemoryPolicy storage.KeyValueStoreMaxMemoryPolicy `help:"MaxMemoryPolicy specifies the exact behavior KeyValueStore follows when the maxmemory limit is reached." placeholder:"allkeys-lru"` - AllowedCidrs []meta.IPv4CIDR `help:"AllowedCIDRs specify the allowed IP addresses, connecting to the instance." placeholder:"203.0.113.1/32"` - PublicNetworkingEnabled *bool `help:"Specifies if the service should be available without service connection." placeholder:"true"` + Location meta.LocationName `placeholder:"${keyvaluestore_location_default}" help:"Where the Key-Value Store instance is created. Available locations are: ${keyvaluestore_location_options}"` + MemorySize *storage.KeyValueStoreMemorySize `placeholder:"${keyvaluestore_memorysize_default}" help:"Available amount of memory."` + MaxMemoryPolicy storage.KeyValueStoreMaxMemoryPolicy `placeholder:"${keyvaluestore_maxmemorypolicy_default}" help:"Behaviour when the memory limit is reached."` + AllowedCidrs []meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"IP addresses allowed to connect to the public endpoint."` + PublicNetworking *bool `negatable:"" help:"Enable or disable public networking. Enabled by default."` + + // Deprecated Flags + PublicNetworkingEnabled *bool `hidden:""` } func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error { @@ -51,9 +54,25 @@ func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error }) } +// KeyValueStoreKongVars returns all variables which are used in the KeyValueStore +// create command. +func KeyValueStoreKongVars() kong.Vars { + result := make(kong.Vars) + result["keyvaluestore_memorysize_default"] = storage.KeyValueStoreMemorySizeDefault + result["keyvaluestore_maxmemorypolicy_default"] = string(storage.KeyValueStoreMaxMemoryPolicyDefault) + result["keyvaluestore_location_options"] = strings.Join(stringSlice(storage.KeyValueStoreLocationOptions), ", ") + result["keyvaluestore_location_default"] = string(storage.KeyValueStoreLocationDefault) + return result +} + func (cmd *keyValueStoreCmd) newKeyValueStore(namespace string) (*storage.KeyValueStore, error) { name := getName(cmd.Name) + publicNetworking := cmd.PublicNetworking + if publicNetworking == nil { + publicNetworking = cmd.PublicNetworkingEnabled + } + keyValueStore := &storage.KeyValueStore{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -67,22 +86,14 @@ func (cmd *keyValueStoreCmd) newKeyValueStore(namespace string) (*storage.KeyVal }, }, ForProvider: storage.KeyValueStoreParameters{ - Location: meta.LocationName(cmd.Location), + Location: cmd.Location, MaxMemoryPolicy: cmd.MaxMemoryPolicy, AllowedCIDRs: cmd.AllowedCidrs, - PublicNetworkingEnabled: cmd.PublicNetworkingEnabled, + PublicNetworkingEnabled: publicNetworking, + MemorySize: cmd.MemorySize, }, }, } - if cmd.MemorySize != "" { - q, err := resource.ParseQuantity(cmd.MemorySize) - if err != nil { - return keyValueStore, fmt.Errorf("error parsing memory size %q: %w", cmd.MemorySize, err) - } - - keyValueStore.Spec.ForProvider.MemorySize = &storage.KeyValueStoreMemorySize{Quantity: q} - } - return keyValueStore, nil } diff --git a/create/keyvaluestore_test.go b/create/keyvaluestore_test.go index 770eae50..81137245 100644 --- a/create/keyvaluestore_test.go +++ b/create/keyvaluestore_test.go @@ -29,7 +29,7 @@ func TestKeyValueStore(t *testing.T) { }, { name: "memorySize", - create: keyValueStoreCmd{MemorySize: "1G"}, + create: keyValueStoreCmd{MemorySize: ptr.To(storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")})}, want: storage.KeyValueStoreParameters{ MemorySize: &storage.KeyValueStoreMemorySize{ Quantity: resource.MustParse("1G"), @@ -55,7 +55,7 @@ func TestKeyValueStore(t *testing.T) { }, }, { - name: "publicNetworking", + name: "publicNetworking-deprecated", create: keyValueStoreCmd{ PublicNetworkingEnabled: ptr.To(true), }, @@ -64,10 +64,41 @@ func TestKeyValueStore(t *testing.T) { }, }, { - name: "invalid", - create: keyValueStoreCmd{MemorySize: "invalid"}, - want: storage.KeyValueStoreParameters{}, - wantErr: true, + name: "publicNetworking", + create: keyValueStoreCmd{ + PublicNetworking: ptr.To(true), + }, + want: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + }, + { + name: "publicNetworking-disabled-deprecated", + create: keyValueStoreCmd{ + PublicNetworkingEnabled: ptr.To(false), + }, + want: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, + { + name: "publicNetworking-disabled", + create: keyValueStoreCmd{ + PublicNetworking: ptr.To(false), + }, + want: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, + { + name: "publicNetworking-disabled-both", + create: keyValueStoreCmd{ + PublicNetworking: ptr.To(false), + PublicNetworkingEnabled: ptr.To(true), + }, + want: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, }, } for _, tt := range tests { diff --git a/create/mysql.go b/create/mysql.go index 2c8c289a..41c27153 100644 --- a/create/mysql.go +++ b/create/mysql.go @@ -1,8 +1,10 @@ package create import ( + "bufio" "context" "fmt" + "os" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -15,16 +17,15 @@ import ( storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/file" ) type mySQLCmd struct { resourceCmd - Location string `placeholder:"${mysql_location_default}" help:"Location where the MySQL instance is created. Available locations are: ${mysql_location_options}"` - MachineType string `placeholder:"${mysql_machine_default}" help:"Defines the sizing for a particular MySQL instance. Available types: ${mysql_machine_types}"` - AllowedCidrs []meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"Specifies the IP addresses allowed to connect to the instance." ` - SSHKeys []storage.SSHKey `help:"Contains a list of SSH public keys, allowed to connect to the db server, in order to up-/download and directly restore database backups."` - SSHKeysFile string `help:"Path to a file containing a list of SSH public keys (see above), separated by newlines."` + Location meta.LocationName `placeholder:"${mysql_location_default}" help:"Where the MySQL instance is created. Available locations are: ${mysql_location_options}"` + MachineType string `placeholder:"${mysql_machine_default}" help:"Sizing for a particular MySQL instance. Available types: ${mysql_machine_types}"` + AllowedCidrs []meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"IP addresses allowed to connect to the instance."` + SSHKeys []storage.SSHKey `help:"SSH public keys allowed to connect to the database server in order to up-/download and directly restore database backups."` + SSHKeysFile *os.File `predictor:"file" help:"Path to a file containing a list of SSH public keys (see above), separated by newlines. Lines prefixed with # are ignored."` SQLMode *[]storage.MySQLMode `placeholder:"\"MODE1, MODE2, ...\"" help:"Configures the sql_mode setting. Modes affect the SQL syntax MySQL supports and the data validation checks it performs. Defaults to: ${mysql_mode}"` CharacterSetName string `placeholder:"${mysql_charset}" help:"Configures the character_set_server variable."` CharacterSetCollation string `placeholder:"${mysql_collation}" help:"Configures the collation_server variable."` @@ -35,11 +36,15 @@ type mySQLCmd struct { } func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error { - sshkeys, err := file.ReadSSHKeys(cmd.SSHKeysFile) - if err != nil { - return fmt.Errorf("error when reading SSH keys file: %w", err) + if cmd.SSHKeysFile != nil { + defer cmd.SSHKeysFile.Close() + + keys, err := ParseSSHKeys(cmd.SSHKeysFile) + if err != nil { + return err + } + cmd.SSHKeys = keys } - cmd.SSHKeys = append(cmd.SSHKeys, sshkeys...) fmt.Printf("Creating new mysql. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) mysql := cmd.newMySQL(client.Project) @@ -83,7 +88,7 @@ func (cmd *mySQLCmd) newMySQL(namespace string) *storage.MySQL { }, }, ForProvider: storage.MySQLParameters{ - Location: meta.LocationName(cmd.Location), + Location: cmd.Location, MachineType: infra.NewMachineType(cmd.MachineType), AllowedCIDRs: []meta.IPv4CIDR{}, // avoid missing parameter error SSHKeys: []storage.SSHKey{}, // avoid missing parameter error @@ -114,7 +119,7 @@ func (cmd *mySQLCmd) newMySQL(namespace string) *storage.MySQL { // create command func MySQLKongVars() kong.Vars { result := make(kong.Vars) - result["mysql_machine_types"] = strings.Join(mtStringSlice(storage.MySQLMachineTypes), ", ") + result["mysql_machine_types"] = strings.Join(stringerSlice(storage.MySQLMachineTypes), ", ") result["mysql_machine_default"] = storage.MySQLMachineTypeDefault.String() result["mysql_location_options"] = strings.Join(storage.MySQLLocationOptions, ", ") result["mysql_location_default"] = string(storage.MySQLLocationDefault) @@ -129,10 +134,23 @@ func MySQLKongVars() kong.Vars { return result } -func mtStringSlice(machineTypes []infra.MachineType) []string { - types := make([]string, len(machineTypes)) - for i, machineType := range storage.MySQLMachineTypes { - types[i] = machineType.String() +// ParseSSHKeys parses the SSH keys from the given file. +func ParseSSHKeys(file *os.File) ([]storage.SSHKey, error) { + keys := []storage.SSHKey{} + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + keys = append(keys, storage.SSHKey(scanner.Text())) } - return types + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading SSH keys file: %w", err) + } + + return keys, nil } diff --git a/create/mysqldatabase.go b/create/mysqldatabase.go index 3ed3edfd..3d58bc74 100644 --- a/create/mysqldatabase.go +++ b/create/mysqldatabase.go @@ -18,8 +18,8 @@ import ( type mysqlDatabaseCmd struct { resourceCmd - Location string `placeholder:"${mysqldatabase_location_default}" help:"Location where the MySQL database is created. Available locations are: ${mysqldatabase_location_options}"` - MysqlDatabaseVersion storage.MySQLVersion `placeholder:"${mysqldatabase_version_default}" help:"Release version with which the MySQL database is created. Available versions: ${mysqldatabase_versions}"` + Location meta.LocationName `placeholder:"${mysqldatabase_location_default}" help:"Where the MySQL database is created. Available locations are: ${mysqldatabase_location_options}"` + MysqlDatabaseVersion storage.MySQLVersion `placeholder:"${mysqldatabase_version_default}" help:"Version of the MySQL database. Available versions: ${mysqldatabase_versions}"` CharacterSet string `placeholder:"${mysqldatabase_characterset_default}" help:"Character set for the MySQL database. Available character sets: ${mysqldatabase_characterset_options}"` } @@ -72,7 +72,7 @@ func (cmd *mysqlDatabaseCmd) newMySQLDatabase(namespace string) *storage.MySQLDa }, }, ForProvider: storage.MySQLDatabaseParameters{ - Location: meta.LocationName(cmd.Location), + Location: cmd.Location, Version: cmd.MysqlDatabaseVersion, CharacterSet: storage.MySQLCharacterSet{ Name: cmd.CharacterSet, diff --git a/create/opensearch.go b/create/opensearch.go index 0e5884be..b8d80508 100644 --- a/create/opensearch.go +++ b/create/opensearch.go @@ -2,7 +2,10 @@ package create import ( "context" + "fmt" + "strings" + "github.com/alecthomas/kong" runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" infra "github.com/ninech/apis/infrastructure/v1alpha1" meta "github.com/ninech/apis/meta/v1alpha1" @@ -14,10 +17,15 @@ import ( type openSearchCmd struct { resourceCmd - Location string `help:"Location where the OpenSearch cluster is created." placeholder:"nine-es34"` - MachineType string `help:"MachineType specifies the type of machine to use for the OpenSearch cluster." placeholder:"nine-search-s"` - ClusterType storage.OpenSearchClusterType `help:"ClusterType specifies the type of OpenSearch cluster to create. Options: single, multi" placeholder:"single"` - AllowedCidrs []meta.IPv4CIDR `help:"AllowedCIDRs specify the allowed IP addresses, connecting to the cluster." placeholder:"203.0.113.1/32"` + Location meta.LocationName `placeholder:"${opensearch_location_default}" help:"Where the OpenSearch cluster is created. Available locations are: ${opensearch_location_options}"` + ClusterType storage.OpenSearchClusterType `placeholder:"${opensearch_cluster_type_default}" help:"Type of cluster. Available types: ${opensearch_cluster_types}"` + MachineType string `placeholder:"${opensearch_machine_type_default}" help:"Defines the sizing of an OpenSearch instance. Available types: ${opensearch_machine_types}"` + AllowedCidrs []meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"IP addresses allowed to connect to the public endpoint."` + BucketUsers []LocalReference `placeholder:"user1,user2" help:"BucketUsers specify the users who have read access to the OpenSearch snapshots bucket."` + PublicNetworking *bool `negatable:"" help:"Enable or disable public networking. Enabled by default."` + + // Deprecated Flags + PublicNetworkingEnabled *bool `hidden:"" help:"If public networking is \"false\", it is only possible to access the service by configuring a service connection."` } func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { @@ -49,9 +57,33 @@ func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { }) } +// OpenSearchKongVars returns all variables which are used in the OpenSearch +// create command +func OpenSearchKongVars() kong.Vars { + result := make(kong.Vars) + result["opensearch_machine_types"] = strings.Join(stringerSlice(storage.OpenSearchMachineTypes), ", ") + result["opensearch_machine_type_default"] = storage.OpenSearchMachineTypeDefault.String() + result["opensearch_cluster_types"] = strings.Join(stringSlice(storage.OpenSearchClusterTypes), ", ") + result["opensearch_cluster_type_default"] = string(storage.OpenSearchClusterTypeDefault) + result["opensearch_location_options"] = strings.Join(stringSlice(storage.OpenSearchLocationOptions), ", ") + result["opensearch_location_default"] = string(storage.OpenSearchLocationDefault) + result["opensearch_user"] = string(storage.OpenSearchUser) + return result +} + func (cmd *openSearchCmd) newOpenSearch(namespace string) (*storage.OpenSearch, error) { name := getName(cmd.Name) + publicNetworking := cmd.PublicNetworking + if publicNetworking == nil { + publicNetworking = cmd.PublicNetworkingEnabled + } + + bucketUsers := make([]meta.LocalReference, 0, len(cmd.BucketUsers)) + for _, user := range cmd.BucketUsers { + bucketUsers = append(bucketUsers, user.LocalReference) + } + openSearch := &storage.OpenSearch{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -65,13 +97,32 @@ func (cmd *openSearchCmd) newOpenSearch(namespace string) (*storage.OpenSearch, }, }, ForProvider: storage.OpenSearchParameters{ - Location: meta.LocationName(cmd.Location), - MachineType: infra.NewMachineType(cmd.MachineType), - ClusterType: cmd.ClusterType, - AllowedCIDRs: cmd.AllowedCidrs, + Location: cmd.Location, + MachineType: infra.NewMachineType(cmd.MachineType), + ClusterType: cmd.ClusterType, + AllowedCIDRs: cmd.AllowedCidrs, + BucketUsers: bucketUsers, + PublicNetworkingEnabled: publicNetworking, }, }, } return openSearch, nil } + +// LocalReference references another object in the same namespace. +type LocalReference struct { + meta.LocalReference +} + +// UnmarshalText parses a local reference from a string. +func (r *LocalReference) UnmarshalText(text []byte) error { + name := strings.TrimSpace(string(text)) + if name == "" { + return fmt.Errorf("reference unmarshal error: got %q", text) + } + + r.Name = name + + return nil +} diff --git a/create/opensearch_test.go b/create/opensearch_test.go index 65c2b052..744252af 100644 --- a/create/opensearch_test.go +++ b/create/opensearch_test.go @@ -13,6 +13,7 @@ import ( "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) func TestOpenSearch(t *testing.T) { @@ -57,6 +58,52 @@ func TestOpenSearch(t *testing.T) { AllowedCIDRs: []meta.IPv4CIDR{meta.IPv4CIDR("192.168.1.0/24")}, }, }, + { + name: "publicNetworking-deprecated", + create: openSearchCmd{ + PublicNetworkingEnabled: ptr.To(true), + }, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + }, + { + name: "publicNetworking", + create: openSearchCmd{ + PublicNetworking: ptr.To(true), + }, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + }, + { + name: "publicNetworking-disabled-deprecated", + create: openSearchCmd{ + PublicNetworkingEnabled: ptr.To(false), + }, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, + { + name: "publicNetworking-disabled", + create: openSearchCmd{ + PublicNetworking: ptr.To(false), + }, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, + { + name: "publicNetworking-disabled-both", + create: openSearchCmd{ + PublicNetworking: ptr.To(false), + PublicNetworkingEnabled: ptr.To(true), + }, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/create/postgres.go b/create/postgres.go index 2db2ef60..5545b0dc 100644 --- a/create/postgres.go +++ b/create/postgres.go @@ -3,6 +3,7 @@ package create import ( "context" "fmt" + "os" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -15,26 +16,29 @@ import ( storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/file" ) type postgresCmd struct { resourceCmd - Location string `placeholder:"${postgres_location_default}" help:"Location where the PostgreSQL instance is created. Available locations are: ${postgres_location_options}"` + Location meta.LocationName `placeholder:"${postgres_location_default}" help:"Where the PostgreSQL instance is created. Available locations are: ${postgres_location_options}"` MachineType string `placeholder:"${postgres_machine_default}" help:"Defines the sizing for a particular PostgreSQL instance. Available types: ${postgres_machine_types}"` - AllowedCidrs []meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"Specifies the IP addresses allowed to connect to the instance." ` - SSHKeys []storage.SSHKey `help:"Contains a list of SSH public keys, allowed to connect to the db server, in order to up-/download and directly restore database backups."` - SSHKeysFile string `help:"Path to a file containing a list of SSH public keys (see above), separated by newlines."` + AllowedCidrs []meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"IP addresses allowed to connect to the instance."` + SSHKeys []storage.SSHKey `help:"SSH public keys allowed to connect to the database server in order to up-/download and directly restore database backups."` + SSHKeysFile *os.File `predictor:"file" help:"Path to a file containing a list of SSH public keys (see above), separated by newlines. Lines prefixed with # are ignored."` PostgresVersion storage.PostgresVersion `placeholder:"${postgres_version_default}" help:"Release version with which the PostgreSQL instance is created. Available versions: ${postgres_versions}"` KeepDailyBackups *int `placeholder:"${postgres_backup_retention_days}" help:"Number of daily database backups to keep. Note that setting this to 0, backup will be disabled and existing dumps deleted immediately."` } func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { - sshkeys, err := file.ReadSSHKeys(cmd.SSHKeysFile) - if err != nil { - return fmt.Errorf("error when reading SSH keys file: %w", err) + if cmd.SSHKeysFile != nil { + defer cmd.SSHKeysFile.Close() + + keys, err := ParseSSHKeys(cmd.SSHKeysFile) + if err != nil { + return err + } + cmd.SSHKeys = keys } - cmd.SSHKeys = append(cmd.SSHKeys, sshkeys...) fmt.Printf("Creating new postgres. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) postgres := cmd.newPostgres(client.Project) @@ -78,7 +82,7 @@ func (cmd *postgresCmd) newPostgres(namespace string) *storage.Postgres { }, }, ForProvider: storage.PostgresParameters{ - Location: meta.LocationName(cmd.Location), + Location: cmd.Location, MachineType: infra.NewMachineType(cmd.MachineType), AllowedCIDRs: []meta.IPv4CIDR{}, // avoid missing parameter error SSHKeys: []storage.SSHKey{}, // avoid missing parameter error @@ -102,7 +106,7 @@ func (cmd *postgresCmd) newPostgres(namespace string) *storage.Postgres { // create command func PostgresKongVars() kong.Vars { result := make(kong.Vars) - result["postgres_machine_types"] = strings.Join(mtStringSlice(storage.PostgresMachineTypes), ", ") + result["postgres_machine_types"] = strings.Join(stringerSlice(storage.PostgresMachineTypes), ", ") result["postgres_machine_default"] = storage.PostgresMachineTypeDefault.String() result["postgres_location_options"] = strings.Join(storage.PostgresLocationOptions, ", ") result["postgres_location_default"] = string(storage.PostgresLocationDefault) diff --git a/create/postgresdatabase.go b/create/postgresdatabase.go index 11388dee..5060d0c7 100644 --- a/create/postgresdatabase.go +++ b/create/postgresdatabase.go @@ -18,7 +18,7 @@ import ( type postgresDatabaseCmd struct { resourceCmd - Location string `placeholder:"${postgresdatabase_location_default}" help:"Location where the PostgreSQL database is created. Available locations are: ${postgresdatabase_location_options}"` + Location meta.LocationName `placeholder:"${postgresdatabase_location_default}" help:"Where the PostgreSQL database is created. Available locations are: ${postgresdatabase_location_options}"` PostgresDatabaseVersion storage.PostgresVersion `placeholder:"${postgresdatabase_version_default}" help:"Release version with which the PostgreSQL database is created. Available versions: ${postgresdatabase_versions}"` } @@ -71,7 +71,7 @@ func (cmd *postgresDatabaseCmd) newPostgresDatabase(namespace string) *storage.P }, }, ForProvider: storage.PostgresDatabaseParameters{ - Location: meta.LocationName(cmd.Location), + Location: cmd.Location, Version: cmd.PostgresDatabaseVersion, }, }, diff --git a/create/project_config.go b/create/project_config.go index ec01f89a..97cb2356 100644 --- a/create/project_config.go +++ b/create/project_config.go @@ -13,10 +13,10 @@ import ( // all fields need to be pointers so we can detect if they have been set by // the user. type configCmd struct { - Size string `help:"Size of the app."` - Port *int32 `help:"Port the app is listening on."` - Replicas *int32 `help:"Amount of replicas of the running app."` - Env *map[string]string `help:"Environment variables which are passed to the app at runtime."` + Size string `help:"Size of the application."` + Port *int32 `help:"Port the application is listening on."` + Replicas *int32 `help:"Amount of replicas of the running application."` + Env *map[string]string `help:"Environment variables which are passed to the application at runtime."` BasicAuth *bool `help:"Enable/Disable basic authentication for applications."` DeployJob deployJob `embed:"" prefix:"deploy-job-"` } diff --git a/create/serviceconnection.go b/create/serviceconnection.go index 5f5aba24..9da91b97 100644 --- a/create/serviceconnection.go +++ b/create/serviceconnection.go @@ -20,17 +20,17 @@ import ( type serviceConnectionCmd struct { resourceCmd - Source TypedReference `placeholder:"kind/name" help:"The source of the connection in the form kind/name. Allowed source kinds are: ${allowed_sources}." required:""` - Destination TypedReference `placeholder:"kind/name" help:"The 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:"The source namespace of the connection. Defaults to current project."` + 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:""` + SourceNamespace string `help:"Source namespace of the connection. Defaults to current project."` KubernetesClusterOptions KubernetesClusterOptions `embed:"" prefix:"source-"` } // KubernetesClusterOptions // https://pkg.go.dev/github.com/ninech/apis@v0.0.0-20250708054129-4d49f7a6c606/networking/v1alpha1#KubernetesClusterOptions type KubernetesClusterOptions struct { - PodSelector *LabelSelector `placeholder:"${label_selector_placeholder}" help:"${label_selector_requirements} Can be used to restrict which pods of the KubernetesCluster can connect to the service connection destination. If left empty, all pods are allowed. If the namespace selector is also set, then the pod selector as a whole selects the pods matching pod selector in the namespaces selected by namespace selector.\n\n${label_selector_usage}"` - NamespaceSelector *LabelSelector `placeholder:"${label_selector_placeholder}" help:"${label_selector_requirements} Selects namespaces using labels set on namespaces. If left empty, it selects all namespaces. It can be used to further restrict the pods selected by the PodSelector.\n\n${label_selector_usage}"` + PodSelector *LabelSelector `placeholder:"${label_selector_placeholder}" help:"${label_selector_requirements} Restrict which pods of the KubernetesCluster can connect to the service connection destination. If left empty, all pods are allowed. If the namespace selector is also set, then the pod selector as a whole selects the pods matching pod selector in the namespaces selected by namespace selector.\n\n${label_selector_usage}."` + NamespaceSelector *LabelSelector `placeholder:"${label_selector_placeholder}" help:"${label_selector_requirements} Select namespaces using labels set on namespaces. If left empty, all namespaces are selected. Allows to further restrict the pods selected by the PodSelector.\n\n${label_selector_usage}."` } // APIType returns the API type [networking.KubernetesClusterOptions] of the [KubernetesClusterOptions]. diff --git a/create/vcluster.go b/create/vcluster.go index 3f0fd163..f6ef401e 100644 --- a/create/vcluster.go +++ b/create/vcluster.go @@ -15,12 +15,12 @@ import ( type vclusterCmd struct { resourceCmd - Location string `default:"nine-es34" help:"Location where the vcluster is created."` - KubernetesVersion string `default:"" help:"Kubernetes version to use. API default will be used if not specified."` - MinNodes int `default:"1" help:"Minimum amount of nodes."` - MaxNodes int `default:"1" help:"Maximum amount of nodes."` - MachineType string `default:"nine-standard-1" help:"Machine type to use for the nodes."` - NodePoolName string `default:"worker" help:"Name of the default node pool in the vcluster."` + Location meta.LocationName `default:"nine-es34" help:"Where the vCluster is created."` + KubernetesVersion string `default:"" help:"Kubernetes version to use. The API default will be used if not specified."` + MinNodes int `default:"1" help:"Minimum amount of nodes."` + MaxNodes int `default:"1" help:"Maximum amount of nodes."` + MachineType string `default:"nine-standard-1" help:"Machine type to use for the vCluster nodes."` + NodePoolName string `default:"worker" help:"Name of the default node pool in the vCluster."` } func (vc *vclusterCmd) Run(ctx context.Context, client *api.Client) error { @@ -75,7 +75,7 @@ func (vc *vclusterCmd) newCluster(project string) *infrastructure.KubernetesClus VCluster: &infrastructure.VClusterSettings{ Version: vc.KubernetesVersion, }, - Location: meta.LocationName(vc.Location), + Location: vc.Location, NodePools: []infrastructure.NodePool{ { Name: vc.NodePoolName, diff --git a/delete/delete.go b/delete/delete.go index c06a1a71..7529269a 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -3,6 +3,7 @@ package delete import ( "context" "fmt" + "os" "time" "github.com/crossplane/crossplane-runtime/pkg/resource" @@ -12,7 +13,7 @@ import ( ) type Cmd struct { - Filename string `short:"f" predictor:"file"` + Filename *os.File `short:"f" predictor:"file"` FromFile fromFile `cmd:"" default:"1" name:"-f " help:"Delete any resource from a yaml or json file."` VCluster vclusterCmd `cmd:"" group:"infrastructure.nine.ch" name:"vcluster" help:"Delete a vcluster."` APIServiceAccount apiServiceAccountCmd `cmd:"" group:"iam.nine.ch" name:"apiserviceaccount" aliases:"asa" help:"Delete an API Service Account."` @@ -33,7 +34,7 @@ type Cmd struct { type resourceCmd struct { Name string `arg:"" predictor:"resource_name" help:"Name of the resource to delete."` Force bool `default:"false" help:"Do not ask for confirmation of deletion."` - Wait bool `default:"true" help:"Wait until resource is fully deleted"` + Wait bool `default:"true" help:"Wait until resource is fully deleted."` WaitTimeout time.Duration `default:"5m" help:"Duration to wait for the deletion. Only relevant if wait is set."` } diff --git a/edit/edit.go b/edit/edit.go index 2a456588..d213eccd 100644 --- a/edit/edit.go +++ b/edit/edit.go @@ -33,7 +33,7 @@ type Cmd struct { MySQLDatabase resourceCmd `cmd:"" group:"storage.nine.ch" name:"mysqldatabase" help:"Edit a MySQL database."` Postgres resourceCmd `cmd:"" group:"storage.nine.ch" name:"postgres" help:"Edit a PostgreSQL instance."` PostgresDatabase resourceCmd `cmd:"" group:"storage.nine.ch" name:"postgresdatabase" help:"Edit a PostgreSQL database."` - KeyValueStore resourceCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Edit a KeyValueStore instance"` + KeyValueStore resourceCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Edit a KeyValueStore instance."` OpenSearch resourceCmd `cmd:"" group:"storage.nine.ch" name:"opensearch" aliases:"os" help:"Edit an OpenSearch cluster."` CloudVirtualMachine resourceCmd `cmd:"" group:"infrastructure.nine.ch" name:"cloudvirtualmachine" aliases:"cloudvm" help:"Edit a CloudVM."` } diff --git a/exec/application.go b/exec/application.go index fed13d4d..1c00fc87 100644 --- a/exec/application.go +++ b/exec/application.go @@ -44,10 +44,10 @@ type remoteCommandParameters struct { type applicationCmd struct { resourceCmd - Stdin bool `name:"stdin" short:"i" help:"Pass stdin to the application" default:"true"` - Tty bool `name:"tty" short:"t" help:"Stdin is a TTY" default:"true"` - WorkerJob string `name:"worker-job" short:"w" help:"Exec into worker job by name"` - Command []string `arg:"" help:"command to execute" optional:""` + Stdin bool `name:"stdin" short:"i" help:"Pass stdin to the application." default:"true"` + Tty bool `name:"tty" short:"t" help:"Stdin is a TTY." default:"true"` + WorkerJob string `name:"worker-job" short:"w" help:"Exec into worker job by name."` + Command []string `arg:"" help:"Command to execute." optional:""` } // Help displays examples for the application exec command diff --git a/get/all.go b/get/all.go index 8ba8a446..d6dd6e62 100644 --- a/get/all.go +++ b/get/all.go @@ -22,8 +22,8 @@ import ( type allCmd struct { stdErr io.Writer - Kinds []string `help:"specify the kind of resources which should be listed"` - IncludeNineResources bool `help:"show resources which are owned by Nine" default:"false"` + Kinds []string `help:"Specify the kind of resources which should be listed."` + IncludeNineResources bool `help:"Show resources which are owned by Nine." default:"false"` } func (cmd *allCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { diff --git a/get/all_test.go b/get/all_test.go index 9978864f..de9c54cd 100644 --- a/get/all_test.go +++ b/get/all_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "os" + "regexp" "testing" apps "github.com/ninech/apis/apps/v1alpha1" @@ -15,8 +16,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" + sigs "sigs.k8s.io/controller-runtime/pkg/client" + fake "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func TestAllContent(t *testing.T) { @@ -25,8 +26,8 @@ func TestAllContent(t *testing.T) { organization := "evilcorp" for name, testCase := range map[string]struct { - projects []client.Object - objects []client.Object + projects []sigs.Object + objects []sigs.Object projectName string outputFormat outputFormat allProjects bool @@ -34,36 +35,32 @@ func TestAllContent(t *testing.T) { kinds []string output string errorExpected bool + expectRegexp *regexp.Regexp }{ "all resources from one project, full format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, + objects: []sigs.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, outputFormat: full, projectName: "dev", - output: `PROJECT NAME KIND GROUP -dev banana Application apps.nine.ch -dev pear Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+KIND\s+GROUP\ndev\s+banana\s+Application\s+apps.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\n`), }, "all resources from one project, no header": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, + objects: []sigs.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, outputFormat: noHeader, projectName: "dev", - output: `dev banana Application apps.nine.ch -dev pear Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`dev\s+banana\s+Application\s+apps.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\n`), }, "all resources from one project, yaml format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, + objects: []sigs.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, outputFormat: yamlOut, projectName: "dev", output: "apiVersion: apps.nine.ch/v1alpha1\nkind: Application\nmetadata:\n creationTimestamp: null\n name: banana\n namespace: dev\nspec:\n forProvider:\n buildEnv: null\n config:\n env: null\n port: null\n replicas: null\n size: \"\"\n dockerfileBuild:\n enabled: false\n git:\n revision: \"\"\n subPath: \"\"\n url: \"\"\n paused: false\nstatus:\n atProvider:\n defaultURLs: null\n---\napiVersion: apps.nine.ch/v1alpha1\ncreationTimestampNano: 0\nkind: Release\nmetadata:\n creationTimestamp: null\n name: pear\n namespace: dev\nspec:\n forProvider:\n build:\n name: \"\"\n configuration:\n size:\n origin: \"\"\n value: \"\"\n defaultHosts: null\n healthProbeConfiguration: null\n image: {}\n paused: false\nstatus:\n atProvider:\n owning: false\n", }, "all resources from one project, json format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, + objects: []sigs.Object{testApplication("banana", "dev"), testRelease("pear", "dev")}, outputFormat: jsonOut, projectName: "dev", output: `[ @@ -138,54 +135,43 @@ dev pear Release apps.nine.ch }, "all projects, full format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{ + objects: []sigs.Object{ testApplication("banana", "dev"), testRelease("pear", "dev"), testApplication("apple", "staging"), testRelease("melon", "staging"), testCluster("orange", "prod"), }, outputFormat: full, allProjects: true, - output: `PROJECT NAME KIND GROUP -dev banana Application apps.nine.ch -dev pear Release apps.nine.ch -prod orange KubernetesCluster infrastructure.nine.ch -staging apple Application apps.nine.ch -staging melon Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+KIND\s+GROUP\ndev\s+banana\s+Application\s+apps.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\nprod\s+orange\s+KubernetesCluster\s+infrastructure.nine.ch\nstaging\s+apple\s+Application\s+apps.nine.ch\nstaging\s+melon\s+Release\s+apps.nine.ch\n`), }, "all projects, no headers format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{ + objects: []sigs.Object{ testApplication("banana", "dev"), testRelease("pear", "dev"), testApplication("apple", "staging"), testRelease("melon", "staging"), testCluster("orange", "prod"), }, outputFormat: noHeader, allProjects: true, - output: `dev banana Application apps.nine.ch -dev pear Release apps.nine.ch -prod orange KubernetesCluster infrastructure.nine.ch -staging apple Application apps.nine.ch -staging melon Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`dev\s+banana\s+Application\s+apps.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\nprod\s+orange\s+KubernetesCluster\s+infrastructure.nine.ch\nstaging\s+apple\s+Application\s+apps.nine.ch\nstaging\s+melon\s+Release\s+apps.nine.ch\n`), }, "empty resources of a specific project, full format": { projects: test.Projects(organization, "dev"), - objects: []client.Object{}, + objects: []sigs.Object{}, outputFormat: full, projectName: "dev", output: "no Resources found in project dev\n", }, "empty resources of all projects, full format": { projects: test.Projects(organization, "dev", "staging"), - objects: []client.Object{}, + objects: []sigs.Object{}, outputFormat: full, allProjects: true, output: "no Resources found in any project\n", }, "filter nine resources, no headers format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{ + objects: []sigs.Object{ testApplication("banana", "dev"), testRelease("pear", "dev"), testApplication("apple", "staging"), testRelease("melon", "staging"), testRelease("cherry", "staging"), testCluster("orange", "prod"), @@ -199,17 +185,11 @@ staging melon Release apps.nine.ch }, outputFormat: noHeader, allProjects: true, - output: `dev banana Application apps.nine.ch -dev pear Release apps.nine.ch -prod orange KubernetesCluster infrastructure.nine.ch -staging apple Application apps.nine.ch -staging cherry Release apps.nine.ch -staging melon Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`dev\s+banana\s+Application\s+apps.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\nprod\s+orange\s+KubernetesCluster\s+infrastructure.nine.ch\nstaging\s+apple\s+Application\s+apps.nine.ch\nstaging\s+cherry\s+Release\s+apps.nine.ch\nstaging\s+melon\s+Release\s+apps.nine.ch\n`), }, "include nine resources, no headers format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{ + objects: []sigs.Object{ testApplication("banana", "dev"), testRelease("pear", "dev"), testApplication("apple", "staging"), testRelease("melon", "staging"), testRelease("cherry", "staging"), testCluster("orange", "prod"), @@ -224,18 +204,11 @@ staging melon Release apps.nine.ch outputFormat: noHeader, allProjects: true, includeNineResources: true, - output: `dev banana Application apps.nine.ch -dev kiwi Application apps.nine.ch -dev pear Release apps.nine.ch -prod orange KubernetesCluster infrastructure.nine.ch -staging apple Application apps.nine.ch -staging cherry Release apps.nine.ch -staging melon Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`dev\s+banana\s+Application\s+apps.nine.ch\ndev\s+kiwi\s+Application\s+apps.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\nprod\s+orange\s+KubernetesCluster\s+infrastructure.nine.ch\nstaging\s+apple\s+Application\s+apps.nine.ch\nstaging\s+cherry\s+Release\s+apps.nine.ch\nstaging\s+melon\s+Release\s+apps.nine.ch\n`), }, "only certain kind": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{ + objects: []sigs.Object{ testApplication("banana", "dev"), testRelease("pear", "dev"), testApplication("apple", "staging"), testRelease("melon", "staging"), testRelease("cherry", "staging"), testCluster("orange", "prod"), @@ -243,14 +216,11 @@ staging melon Release apps.nine.ch outputFormat: full, allProjects: true, kinds: []string{"application"}, - output: `PROJECT NAME KIND GROUP -dev banana Application apps.nine.ch -staging apple Application apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+KIND\s+GROUP\ndev\s+banana\s+Application\s+apps.nine.ch\nstaging\s+apple\s+Application\s+apps.nine.ch\n`), }, "multiple certain kinds, no header format": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{ + objects: []sigs.Object{ testApplication("banana", "dev"), testRelease("pear", "dev"), testApplication("apple", "staging"), testRelease("melon", "staging"), testRelease("cherry", "staging"), testCluster("orange", "prod"), @@ -259,16 +229,11 @@ staging apple Application apps.nine.ch outputFormat: noHeader, allProjects: true, kinds: []string{"release", "kubernetescluster"}, - output: `dev dragonfruit KubernetesCluster infrastructure.nine.ch -dev pear Release apps.nine.ch -prod orange KubernetesCluster infrastructure.nine.ch -staging cherry Release apps.nine.ch -staging melon Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`dev\s+dragonfruit\s+KubernetesCluster\s+infrastructure.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\nprod\s+orange\s+KubernetesCluster\s+infrastructure.nine.ch\nstaging\s+cherry\s+Release\s+apps.nine.ch\nstaging\s+melon\s+Release\s+apps.nine.ch\n`), }, "not known kind leads to an error": { projects: test.Projects(organization, "dev", "staging", "prod"), - objects: []client.Object{}, + objects: []sigs.Object{}, outputFormat: noHeader, allProjects: true, kinds: []string{"jackofalltrades"}, @@ -276,16 +241,13 @@ staging melon Release apps.nine.ch }, "excluded list kinds are not shown": { projects: test.Projects(organization, "dev"), - objects: []client.Object{ + objects: []sigs.Object{ testApplication("banana", "dev"), testRelease("pear", "dev"), testClusterData(), }, outputFormat: full, allProjects: true, - output: `PROJECT NAME KIND GROUP -dev banana Application apps.nine.ch -dev pear Release apps.nine.ch -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+KIND\s+GROUP\ndev\s+banana\s+Application\s+apps.nine.ch\ndev\s+pear\s+Release\s+apps.nine.ch\n`), }, } { t.Run(name, func(t *testing.T) { @@ -307,15 +269,15 @@ dev pear Release apps.nine.ch client := fake.NewClientBuilder(). WithScheme(scheme). - WithIndex(&management.Project{}, "metadata.name", func(o client.Object) []string { + WithIndex(&management.Project{}, "metadata.name", func(o sigs.Object) []string { return []string{o.GetName()} }). WithObjects(append(testCase.projects, testCase.objects...)...).Build() apiClient := &api.Client{WithWatch: client, Project: testCase.projectName} - kubeconfig, err := test.CreateTestKubeconfig(apiClient, organization) + kubecfg, err := test.CreateTestKubeconfig(apiClient, organization) require.NoError(t, err) - defer os.Remove(kubeconfig) + defer os.Remove(kubecfg) cmd := allCmd{ IncludeNineResources: testCase.includeNineResources, @@ -328,7 +290,11 @@ dev pear Release apps.nine.ch return } require.NoError(t, err) - assert.Equal(t, testCase.output, outputBuffer.String()) + if testCase.expectRegexp != nil { + assert.Regexp(t, testCase.expectRegexp, outputBuffer.String()) + } else { + assert.Equal(t, testCase.output, outputBuffer.String()) + } }) } } diff --git a/get/application_test.go b/get/application_test.go index 611ac092..b6514656 100644 --- a/get/application_test.go +++ b/get/application_test.go @@ -3,6 +3,7 @@ package get import ( "bytes" "context" + "regexp" "testing" apps "github.com/ninech/apis/apps/v1alpha1" @@ -94,6 +95,7 @@ func TestApplicationCredentials(t *testing.T) { project string output string errorExpected bool + expectRegexp *regexp.Regexp }{ "no basic auth configured, all apps in project": { resources: []client.Object{newBasicAuthApplication("dev", "dev", "")}, @@ -135,9 +137,7 @@ func TestApplicationCredentials(t *testing.T) { }, outputFormat: full, project: "dev", - output: `PROJECT NAME USERNAME PASSWORD -dev dev dev sample -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+USERNAME\s+PASSWORD\ndev\s+dev\s+dev\s+sample\n`), }, "basic auth configured in one app and all apps in the project requested, no header format": { resources: []client.Object{ @@ -153,7 +153,7 @@ dev dev dev sample }, project: "dev", outputFormat: noHeader, - output: "dev dev dev sample\n", + expectRegexp: regexp.MustCompile(`dev\s+dev\s+dev\s+sample\n`), }, "basic auth configured in one app and all apps in the project requested, yaml format": { resources: []client.Object{ @@ -220,10 +220,7 @@ dev dev dev sample }, outputFormat: full, project: "dev", - output: `PROJECT NAME USERNAME PASSWORD -dev dev dev sample -dev dev-second dev-second sample-second -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+USERNAME\s+PASSWORD\ndev\s+dev\s+dev\s+sample\ndev\s+dev-second\s+dev-second\s+sample-second\n`), }, "multiple apps in different projects and all apps requested, yaml format": { resources: []client.Object{ @@ -325,7 +322,11 @@ dev dev-second dev-second sample-second return } require.NoError(t, err) - assert.Equal(t, testCase.output, buf.String()) + if testCase.expectRegexp != nil { + assert.Regexp(t, testCase.expectRegexp, buf.String()) + } else { + assert.Equal(t, testCase.output, buf.String()) + } }) } } @@ -342,6 +343,7 @@ func TestApplicationDNS(t *testing.T) { project string output string errorExpected bool + expectRegexp *regexp.Regexp }{ "no DNS set yet - full format": { apps: []client.Object{ @@ -354,11 +356,7 @@ func TestApplicationDNS(t *testing.T) { }, outputFormat: full, project: "dev", - output: `PROJECT NAME TXT RECORD DNS TARGET -dev no-txt-record - -Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+TXT RECORD\s+DNS TARGET\ndev\s+no-txt-record\s+\s+\n\nVisit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts\n`), }, "DNS set - one application - full format": { apps: []client.Object{ @@ -371,11 +369,7 @@ Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup cust }, outputFormat: full, project: "dev", - output: `PROJECT NAME TXT RECORD DNS TARGET -dev sample deploio-site-verification=sample-dev-3ksdk23 sample.3ksdk23.deploio.app - -Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+TXT RECORD\s+DNS TARGET\ndev\s+sample\s+deploio-site-verification=sample-dev-3ksdk23\s+sample.3ksdk23.deploio.app\n\nVisit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts\n`), }, "DNS set - one application - no header format": { apps: []client.Object{ @@ -388,10 +382,7 @@ Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup cust }, outputFormat: noHeader, project: "dev", - output: `dev sample deploio-site-verification=sample-dev-3ksdk23 sample.3ksdk23.deploio.app - -Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts -`, + expectRegexp: regexp.MustCompile(`dev\s+sample\s+deploio-site-verification=sample-dev-3ksdk23\s+sample.3ksdk23.deploio.app\n\nVisit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts\n`), }, "multiple applications in multiple projects - full format": { apps: []client.Object{ @@ -409,12 +400,7 @@ Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup cust ), }, outputFormat: full, - output: `PROJECT NAME TXT RECORD DNS TARGET -dev sample deploio-site-verification=sample-dev-3ksdk23 sample.3ksdk23.deploio.app -test test deploio-site-verification=test-test-4ksdk23 test.4ksdk23.deploio.app - -Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+TXT RECORD\s+DNS TARGET\ndev\s+sample\s+deploio-site-verification=sample-dev-3ksdk23\s+sample.3ksdk23.deploio.app\ntest\s+test\s+deploio-site-verification=test-test-4ksdk23\s+test.4ksdk23.deploio.app\n\nVisit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup custom hosts\n`), }, "multiple applications in one project - yaml format": { apps: []client.Object{ @@ -501,7 +487,11 @@ Visit https://docs.nine.ch/a/myshbw3EY1 to see instructions on how to setup cust return } require.NoError(t, err) - assert.Equal(t, testCase.output, buf.String()) + if testCase.expectRegexp != nil { + assert.Regexp(t, testCase.expectRegexp, buf.String()) + } else { + assert.Equal(t, testCase.output, buf.String()) + } }) } } @@ -566,4 +556,4 @@ func newBasicAuthSecret(name, project string, basicAuth util.BasicAuth) *corev1. util.BasicAuthPasswordKey: []byte(basicAuth.Password), }, } -} +} \ No newline at end of file diff --git a/get/cloudvm.go b/get/cloudvm.go index 9d0ef525..6734ca93 100644 --- a/get/cloudvm.go +++ b/get/cloudvm.go @@ -52,11 +52,11 @@ func (cmd *cloudVMCmd) print(ctx context.Context, client *api.Client, list clien func (cmd *cloudVMCmd) printCloudVirtualMachineInstances(list []infrastructure.CloudVirtualMachine, out *output, header bool) error { if header { - out.writeHeader("NAME", "FQDN", "POWER STATE", "IP ADDRESS") + out.writeHeader("NAME", "LOCATION", "FQDN", "POWER STATE", "IP ADDRESS") } - for _, cloudvm := range list { - out.writeTabRow(cloudvm.Namespace, cloudvm.Name, cloudvm.Status.AtProvider.FQDN, string(cloudvm.Status.AtProvider.PowerState), cloudvm.Status.AtProvider.IPAddress) + for _, vm := range list { + out.writeTabRow(vm.Namespace, vm.Name, string(vm.Spec.ForProvider.Location), vm.Status.AtProvider.FQDN, string(vm.Status.AtProvider.PowerState), vm.Status.AtProvider.IPAddress) } return out.tabWriter.Flush() diff --git a/get/clusters.go b/get/clusters.go index f1a71265..9b45f82d 100644 --- a/get/clusters.go +++ b/get/clusters.go @@ -58,7 +58,7 @@ func (cmd *clustersCmd) print(ctx context.Context, client *api.Client, list clie func printClusters(clusters []infrastructure.KubernetesCluster, out *output, header bool) error { if header { - out.writeHeader("NAME", "PROVIDER", "NUM_NODES") + out.writeHeader("NAME", "LOCATION", "PROVIDER", "NODES") } for _, cluster := range clusters { @@ -75,7 +75,7 @@ func printClusters(clusters []infrastructure.KubernetesCluster, out *output, hea if cluster.Spec.ForProvider.VCluster != nil { provider = "vcluster" } - out.writeTabRow(cluster.Namespace, cluster.Name, provider, strconv.Itoa(numNodes)) + out.writeTabRow(cluster.Namespace, cluster.Name, string(cluster.Spec.ForProvider.Location), provider, strconv.Itoa(numNodes)) } return out.tabWriter.Flush() diff --git a/get/get.go b/get/get.go index e84528e8..81ac6469 100644 --- a/get/get.go +++ b/get/get.go @@ -161,7 +161,7 @@ func (out *output) initOut() { } if out.tabWriter == nil { - out.tabWriter = tabwriter.NewWriter(out.writer, 0, 0, 4, ' ', tabwriter.RememberWidths) + out.tabWriter = tabwriter.NewWriter(out.writer, 0, 0, 2, ' ', tabwriter.RememberWidths) } } diff --git a/get/keyvaluestore.go b/get/keyvaluestore.go index f426dba5..f043a761 100644 --- a/get/keyvaluestore.go +++ b/get/keyvaluestore.go @@ -63,11 +63,20 @@ func (cmd *keyValueStoreCmd) print(ctx context.Context, client *api.Client, list func (cmd *keyValueStoreCmd) printKeyValueStoreInstances(list []storage.KeyValueStore, out *output, header bool) error { if header { - out.writeHeader("NAME", "FQDN", "TLS", "MEMORY SIZE") + out.writeHeader("NAME", "LOCATION", "VERSION", "PRIVATE FQDN", "PUBLIC FQDN", "MEMORY POLICY", "MEMORY SIZE") } - for _, keyValueStore := range list { - out.writeTabRow(keyValueStore.Namespace, keyValueStore.Name, keyValueStore.Status.AtProvider.FQDN, "true", keyValueStore.Spec.ForProvider.MemorySize.String()) + for _, kvs := range list { + out.writeTabRow( + kvs.Namespace, + kvs.Name, + string(kvs.Spec.ForProvider.Location), + string(kvs.Spec.ForProvider.Version), + kvs.Status.AtProvider.PrivateNetworkingFQDN, + kvs.Status.AtProvider.FQDN, + string(kvs.Spec.ForProvider.MaxMemoryPolicy), + kvs.Spec.ForProvider.MemorySize.String(), + ) } return out.tabWriter.Flush() diff --git a/get/mysql.go b/get/mysql.go index 5c810bca..bc0cdd58 100644 --- a/get/mysql.go +++ b/get/mysql.go @@ -3,6 +3,7 @@ package get import ( "context" "fmt" + "strconv" "github.com/crossplane/crossplane-runtime/pkg/resource" storage "github.com/ninech/apis/storage/v1alpha1" @@ -47,11 +48,11 @@ func (cmd *mySQLCmd) printMySQLInstances(resources resource.ManagedList, get *Cm } if header { - get.writeHeader("NAME", "FQDN", "LOCATION", "MACHINE TYPE") + get.writeHeader("NAME", "LOCATION", "VERSION", "FQDN", "MACHINE TYPE", "DISK SIZE", "DATABASES") } for _, db := range dbs.Items { - get.writeTabRow(db.Namespace, db.Name, db.Status.AtProvider.FQDN, string(db.Spec.ForProvider.Location), db.Spec.ForProvider.MachineType.String()) + get.writeTabRow(db.Namespace, db.Name, string(db.Spec.ForProvider.Location), string(db.Spec.ForProvider.Version), db.Status.AtProvider.FQDN, db.Spec.ForProvider.MachineType.String(), db.Status.AtProvider.Size.String(), strconv.Itoa(len(db.Status.AtProvider.Databases))) } return get.tabWriter.Flush() diff --git a/get/mysqldatabase.go b/get/mysqldatabase.go index 01552c17..49012054 100644 --- a/get/mysqldatabase.go +++ b/get/mysqldatabase.go @@ -50,11 +50,11 @@ func (cmd *mysqlDatabaseCmd) printMySQLDatabases(resources resource.ManagedList, } if header { - get.writeHeader("NAME", "FQDN", "LOCATION", "COLLATION", "SIZE", "CONNECTIONS") + get.writeHeader("NAME", "LOCATION", "VERSION", "FQDN", "SIZE", "CONNECTIONS") } for _, db := range dbs.Items { - get.writeTabRow(db.Namespace, db.Name, db.Status.AtProvider.FQDN, string(db.Spec.ForProvider.Location), db.Spec.ForProvider.CharacterSet.Collation, db.Status.AtProvider.Size.String(), strconv.FormatUint(uint64(db.Status.AtProvider.Connections), 10)) + get.writeTabRow(db.Namespace, db.Name, string(db.Spec.ForProvider.Location), string(db.Spec.ForProvider.Version), db.Status.AtProvider.FQDN, db.Status.AtProvider.Size.String(), strconv.FormatUint(uint64(db.Status.AtProvider.Connections), 10)) } return get.tabWriter.Flush() diff --git a/get/opensearch.go b/get/opensearch.go index 33cbb77b..ef7ed9b4 100644 --- a/get/opensearch.go +++ b/get/opensearch.go @@ -76,18 +76,21 @@ func (cmd *openSearchCmd) print(ctx context.Context, client *api.Client, list cl func (cmd *openSearchCmd) printOpenSearchInstances(list []storage.OpenSearch, out *output, header bool) error { if header { - out.writeHeader("NAME", "FQDN", "MACHINE TYPE", "CLUSTER TYPE", "DISK SIZE", "HEALTH") + out.writeHeader("NAME", "LOCATION", "VERSION", "PRIVATE URL", "PUBLIC URL", "MACHINE TYPE", "CLUSTER TYPE", "DISK SIZE", "HEALTH") } - for _, openSearch := range list { + for _, os := range list { out.writeTabRow( - openSearch.Namespace, - openSearch.Name, - openSearch.Status.AtProvider.FQDN, - openSearch.Spec.ForProvider.MachineType.String(), - string(openSearch.Spec.ForProvider.ClusterType), - openSearch.Status.AtProvider.DiskSize.String(), - string(cmd.getClusterHealth(openSearch.Status.AtProvider.ClusterHealth)), + os.Namespace, + os.Name, + string(os.Spec.ForProvider.Location), + string(os.Spec.ForProvider.Version), + string(os.Status.AtProvider.PrivateNetworkingURL), + string(os.Status.AtProvider.URL), + os.Spec.ForProvider.MachineType.String(), + string(os.Spec.ForProvider.ClusterType), + os.Status.AtProvider.DiskSize.String(), + string(cmd.getClusterHealth(os.Status.AtProvider.ClusterHealth)), ) } diff --git a/get/opensearch_test.go b/get/opensearch_test.go index b5b75db5..3edb37e4 100644 --- a/get/opensearch_test.go +++ b/get/opensearch_test.go @@ -190,7 +190,7 @@ func TestOpenSearch(t *testing.T) { objects := []client.Object{} for _, instance := range tt.instances { - created := test.OpenSearch(instance.name, instance.project, string(meta.LocationNineES34)) + created := test.OpenSearch(instance.name, instance.project, meta.LocationNineES34) created.Spec.ForProvider.MachineType = instance.machineType // Set cluster health status if provided diff --git a/get/postgres.go b/get/postgres.go index c020fc36..7ebe0885 100644 --- a/get/postgres.go +++ b/get/postgres.go @@ -3,6 +3,7 @@ package get import ( "context" "fmt" + "strconv" "github.com/crossplane/crossplane-runtime/pkg/resource" storage "github.com/ninech/apis/storage/v1alpha1" @@ -47,11 +48,11 @@ func (cmd *postgresCmd) printPostgresInstances(resources resource.ManagedList, g } if header { - get.writeHeader("NAME", "FQDN", "LOCATION", "MACHINE TYPE") + get.writeHeader("NAME", "LOCATION", "VERSION", "FQDN", "MACHINE TYPE", "DISK SIZE", "DATABASES") } for _, db := range dbs.Items { - get.writeTabRow(db.Namespace, db.Name, db.Status.AtProvider.FQDN, string(db.Spec.ForProvider.Location), db.Spec.ForProvider.MachineType.String()) + get.writeTabRow(db.Namespace, db.Name, string(db.Spec.ForProvider.Location), string(db.Spec.ForProvider.Version), db.Status.AtProvider.FQDN, db.Spec.ForProvider.MachineType.String(), db.Status.AtProvider.Size.String(), strconv.Itoa(len(db.Status.AtProvider.Databases))) } return get.tabWriter.Flush() diff --git a/get/postgresdatabase.go b/get/postgresdatabase.go index 60d407e8..04c22099 100644 --- a/get/postgresdatabase.go +++ b/get/postgresdatabase.go @@ -49,11 +49,11 @@ func (cmd *postgresDatabaseCmd) printPostgresDatabases(resources resource.Manage } if header { - get.writeHeader("NAME", "FQDN", "LOCATION", "SIZE", "CONNECTIONS") + get.writeHeader("NAME", "LOCATION", "VERSION", "FQDN", "SIZE", "CONNECTIONS") } for _, db := range dbs.Items { - get.writeTabRow(db.Namespace, db.Name, db.Status.AtProvider.FQDN, string(db.Spec.ForProvider.Location), db.Status.AtProvider.Size.String(), strconv.FormatUint(uint64(db.Status.AtProvider.Connections), 10)) + get.writeTabRow(db.Namespace, db.Name, string(db.Spec.ForProvider.Location), string(db.Spec.ForProvider.Version), db.Status.AtProvider.FQDN, db.Status.AtProvider.Size.String(), strconv.FormatUint(uint64(db.Status.AtProvider.Connections), 10)) } return get.tabWriter.Flush() diff --git a/get/project_config_test.go b/get/project_config_test.go index 3e4d392a..d2311e87 100644 --- a/get/project_config_test.go +++ b/get/project_config_test.go @@ -3,6 +3,7 @@ package get import ( "bytes" "context" + "regexp" "testing" "time" @@ -25,6 +26,7 @@ func TestProjectConfigs(t *testing.T) { createdConfigs []client.Object expectExactMessage *string expectedLineAmountInOutput *int + expectRegexp *regexp.Regexp }{ "get configs for all projects": { get: &Cmd{ @@ -32,16 +34,16 @@ func TestProjectConfigs(t *testing.T) { Format: full, AllProjects: true, }, - }, - project: "ns-1", - createdConfigs: []client.Object{ - fakeProjectConfig(time.Second*10, "ns-1", "ns-1"), - fakeProjectConfig(time.Second*12, "ns-2", "ns-2"), - fakeProjectConfig(time.Second*13, "ns-3", "ns-3"), - }, - // we expect the header line and 3 project configs - expectedLineAmountInOutput: ptr.To(4), }, + project: "ns-1", + createdConfigs: []client.Object{ + fakeProjectConfig(time.Second*10, "ns-1", "ns-1"), + fakeProjectConfig(time.Second*12, "ns-2", "ns-2"), + fakeProjectConfig(time.Second*13, "ns-3", "ns-3"), + }, + // we expect the header line and 3 project configs + expectedLineAmountInOutput: ptr.To(4), + }, "get config for current project": { get: &Cmd{ output: output{ @@ -90,9 +92,7 @@ func TestProjectConfigs(t *testing.T) { }, }, }, - expectExactMessage: ptr.To( - "PROJECT NAME SIZE REPLICAS PORT ENVIRONMENT_VARIABLES BASIC_AUTH DEPLOY_JOB AGE\nns-4 ns-4 poo=***** false 292y\n", - ), + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+SIZE\s+REPLICAS\s+PORT\s+ENVIRONMENT_VARIABLES\s+BASIC_AUTH\s+DEPLOY_JOB\s+AGE\nns-4\s+ns-4\s+poo=\*\*\*\*\*\s+false\s+\s+292y\n`), }, "non-sensitive env var is shown": { get: &Cmd{ @@ -116,9 +116,7 @@ func TestProjectConfigs(t *testing.T) { }, }, }, - expectExactMessage: ptr.To( - "PROJECT NAME SIZE REPLICAS PORT ENVIRONMENT_VARIABLES BASIC_AUTH DEPLOY_JOB AGE\nns-5 ns-5 goo=banana false 292y\n", - ), + expectRegexp: regexp.MustCompile(`PROJECT\s+NAME\s+SIZE\s+REPLICAS\s+PORT\s+ENVIRONMENT_VARIABLES\s+BASIC_AUTH\s+DEPLOY_JOB\s+AGE\nns-5\s+ns-5\s+goo=banana\s+false\s+\s+292y\n`), }, } @@ -144,10 +142,13 @@ func TestProjectConfigs(t *testing.T) { assert.Equal(t, *tc.expectedLineAmountInOutput, test.CountLines(buf.String()), buf.String()) } - if tc.expectExactMessage == nil { - return + if tc.expectExactMessage != nil { + assert.Equal(t, *tc.expectExactMessage, buf.String(), buf.String()) + } + + if tc.expectRegexp != nil { + assert.Regexp(t, tc.expectRegexp, buf.String(), buf.String()) } - assert.Equal(t, buf.String(), *tc.expectExactMessage, buf.String()) }) } } @@ -173,4 +174,4 @@ func fakeProjectConfig( }, }, } -} +} \ No newline at end of file diff --git a/get/project_test.go b/get/project_test.go index 1134b7d1..c7f1f69a 100644 --- a/get/project_test.go +++ b/get/project_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "os" + "regexp" "testing" management "github.com/ninech/apis/management/v1alpha1" @@ -26,34 +27,24 @@ func TestProject(t *testing.T) { outputFormat outputFormat allProjects bool output string + expectRegexp *regexp.Regexp }{ "projects exist, full format": { projects: test.Projects(organization, "dev", "staging", "prod"), displayNames: []string{"Development", "", "Production"}, outputFormat: full, - output: `PROJECT DISPLAY NAME -dev Development -prod Production -staging -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+DISPLAY NAME\ndev\s+Development\nprod\s+Production\nstaging\s+\n`), }, "projects exist, no header format": { projects: test.Projects(organization, "dev", "staging", "prod"), outputFormat: noHeader, - output: `dev -prod -staging -`, + expectRegexp: regexp.MustCompile(`dev\s+\nprod\s+\nstaging\s+\n`), }, "projects exist and allProjects is set": { projects: test.Projects(organization, "dev", "staging", "prod"), outputFormat: full, allProjects: true, - output: `PROJECT DISPLAY NAME -dev -prod -staging -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+DISPLAY NAME\ndev\s+\nprod\s+\nstaging\s+\n`), }, "no projects exist": { projects: []client.Object{}, @@ -69,9 +60,7 @@ staging projects: test.Projects(organization, "dev", "staging"), name: "dev", outputFormat: full, - output: `PROJECT DISPLAY NAME -dev -`, + expectRegexp: regexp.MustCompile(`PROJECT\s+DISPLAY NAME\ndev\s+\n`), }, "specific project requested, but does not exist": { projects: test.Projects(organization, "staging"), @@ -181,7 +170,11 @@ dev t.Fatal(err) } - assert.Equal(t, testCase.output, buf.String()) + if testCase.expectRegexp != nil { + assert.Regexp(t, testCase.expectRegexp, buf.String()) + } else { + assert.Equal(t, testCase.output, buf.String()) + } }) } } diff --git a/go.mod b/go.mod index 03a9afab..b90137ce 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/moby/moby v28.3.3+incompatible github.com/moby/term v0.5.2 - github.com/ninech/apis v0.0.0-20250919080229-7b9e4faad271 + github.com/ninech/apis v0.0.0-20250923145617-eda423f1b20a github.com/posener/complete v1.2.3 github.com/prometheus/common v0.65.1-0.20250703115700-7f8b2a0d32d3 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index b5f87468..85b0bdd1 100644 --- a/go.sum +++ b/go.sum @@ -579,8 +579,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/ninech/apis v0.0.0-20250919080229-7b9e4faad271 h1:EUdePYKqUvLDKRE8jGUYIwSCoYyDpuMyQIfKEMBLTmg= -github.com/ninech/apis v0.0.0-20250919080229-7b9e4faad271/go.mod h1:v9N/4IvFju6G/Qp6BPanKG+V6VYsTFiytZs2hktr3Yk= +github.com/ninech/apis v0.0.0-20250923145617-eda423f1b20a h1:HPjeOljDm2Nyc/Ypxu39AOgqQCQ6xqOd6sSHyUMnMyI= +github.com/ninech/apis v0.0.0-20250923145617-eda423f1b20a/go.mod h1:v9N/4IvFju6G/Qp6BPanKG+V6VYsTFiytZs2hktr3Yk= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= diff --git a/internal/file/file.go b/internal/file/file.go deleted file mode 100644 index 0902dab3..00000000 --- a/internal/file/file.go +++ /dev/null @@ -1,30 +0,0 @@ -package file - -import ( - "bufio" - "os" - - storage "github.com/ninech/apis/storage/v1alpha1" -) - -// ReadSSHKeys reads SSH keys from the file specified by path -func ReadSSHKeys(path string) ([]storage.SSHKey, error) { - if path == "" { - return []storage.SSHKey{}, nil - } - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - fileScanner := bufio.NewScanner(file) - fileScanner.Split(bufio.ScanLines) - - sshkeys := []storage.SSHKey{} - for fileScanner.Scan() { - sshkeys = append(sshkeys, storage.SSHKey(fileScanner.Text())) - } - - return sshkeys, nil -} diff --git a/internal/test/opensearch.go b/internal/test/opensearch.go index 420dbf98..aa27bf8c 100644 --- a/internal/test/opensearch.go +++ b/internal/test/opensearch.go @@ -7,7 +7,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func OpenSearch(name, project, location string) *storage.OpenSearch { +func OpenSearch(name, project string, location meta.LocationName) *storage.OpenSearch { return &storage.OpenSearch{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -21,7 +21,7 @@ func OpenSearch(name, project, location string) *storage.OpenSearch { }, }, ForProvider: storage.OpenSearchParameters{ - Location: meta.LocationName(location), + Location: location, }, }, } diff --git a/internal/test/redis.go b/internal/test/redis.go index cf591c5b..a3ab3f0c 100644 --- a/internal/test/redis.go +++ b/internal/test/redis.go @@ -7,7 +7,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func KeyValueStore(name, project, location string) *storage.KeyValueStore { +func KeyValueStore(name, project string, location meta.LocationName) *storage.KeyValueStore { return &storage.KeyValueStore{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -21,7 +21,7 @@ func KeyValueStore(name, project, location string) *storage.KeyValueStore { }, }, ForProvider: storage.KeyValueStoreParameters{ - Location: meta.LocationName(location), + Location: location, }, }, } diff --git a/logs/logs.go b/logs/logs.go index a0325238..9c379756 100644 --- a/logs/logs.go +++ b/logs/logs.go @@ -23,12 +23,12 @@ type resourceCmd struct { type logsCmd struct { Follow bool `help:"Follow the logs by live tailing." short:"f"` - Lines int `help:"Amount of lines to output" default:"50" short:"l"` - Since time.Duration `help:"Duration how long to look back for logs" short:"s" default:"${log_retention}"` - From time.Time `help:"Ignore since flag and start looking for logs at this absolute time (RFC3339)" placeholder:"2025-01-01T14:00:00+01:00"` - To time.Time `help:"Ignore since flag and stop looking for logs at this absolute time (RFC3339)" placeholder:"2025-01-01T15:00:00+01:00"` + Lines int `help:"Amount of lines to output." default:"50" short:"l"` + Since time.Duration `help:"Duration how long to look back for logs." short:"s" default:"${log_retention}"` + From time.Time `help:"Ignore since flag and start looking for logs at this absolute time (RFC3339)." placeholder:"2025-01-01T14:00:00+01:00"` + To time.Time `help:"Ignore since flag and stop looking for logs at this absolute time (RFC3339)." placeholder:"2025-01-01T15:00:00+01:00"` Output string `help:"Configures the log output format. ${enum}" short:"o" enum:"default,json" default:"default"` - NoLabels bool `help:"disable labels in log output"` + NoLabels bool `help:"Disable labels in log output."` out log.Output } diff --git a/main.go b/main.go index 5911f574..417a116d 100644 --- a/main.go +++ b/main.go @@ -47,7 +47,7 @@ type rootCommand struct { Auth auth.Cmd `cmd:"" help:"Authenticate with resource."` Completions completion.Completion `cmd:"" help:"Print shell completions."` Create create.Cmd `cmd:"" help:"Create resource."` - Copy copy.Cmd `cmd:"" help:"Copy resource"` + Copy copy.Cmd `cmd:"" help:"Copy resource."` Apply apply.Cmd `cmd:"" help:"Apply resource."` Delete delete.Cmd `cmd:"" help:"Delete resource."` Logs logs.Cmd `cmd:"" help:"Get logs of resource."` @@ -87,27 +87,10 @@ func main() { kong.BindTo(ctx, (*context.Context)(nil)), ) - predictors := []completion.Option{ - completion.WithPredictor("file", complete.PredictFiles("*")), - } apiClientRequired := !noAPIClientRequired(strings.Join(os.Args[1:], " ")) - if apiClientRequired { - predictors = append(predictors, - completion.WithPredictor("resource_name", predictor.NewResourceName(ctx, defaultAPICluster)), - completion.WithPredictor("project_name", predictor.NewResourceNameWithKind( - ctx, defaultAPICluster, management.SchemeGroupVersion.WithKind( - reflect.TypeOf(management.ProjectList{}).Name(), - )), - ), - ) - } else { - // complete needs all used predictors to be defined, so we just use - // [complete.PredictNothing] for those that would require an API client. - predictors = append(predictors, - completion.WithPredictor("resource_name", complete.PredictNothing), - completion.WithPredictor("project_name", complete.PredictNothing), - ) - } + predictors := append([]completion.Option{ + completion.WithPredictor("file", complete.PredictFiles("*")), + }, clientPredictors(ctx, apiClientRequired)...) completion.Register(parser, predictors...) kongCtx, err := parser.Parse(os.Args[1:]) @@ -149,6 +132,31 @@ func main() { } } +func clientPredictors(ctx context.Context, apiClientRequired bool) []completion.Option { + // complete needs all used predictors to be defined, so we just use + // [complete.PredictNothing] for those that would require an API client. + nothing := []completion.Option{ + completion.WithPredictor("resource_name", complete.PredictNothing), + completion.WithPredictor("project_name", complete.PredictNothing), + } + + if !apiClientRequired { + return nothing + } + + client, err := predictor.NewClient(ctx, defaultAPICluster) + if err != nil { + return nothing + } + + return []completion.Option{ + completion.WithPredictor("resource_name", predictor.NewResourceName(client)), + completion.WithPredictor("project_name", predictor.NewResourceNameWithKind(client, + management.SchemeGroupVersion.WithKind(reflect.TypeOf(management.ProjectList{}).Name())), + ), + } +} + // noAPIClientRequired returns true if the command does not need to (or can't) // require an API client. func noAPIClientRequired(command string) bool { @@ -197,6 +205,8 @@ func kongVariables() (kong.Vars, error) { create.MySQLDatabaseKongVars(), create.PostgresKongVars(), create.PostgresDatabaseKongVars(), + create.KeyValueStoreKongVars(), + create.OpenSearchKongVars(), create.ServiceConnectionKongVars(), create.BucketUserKongVars(), auth.LoginKongVars(), diff --git a/predictor/predictor.go b/predictor/predictor.go index c98c4213..aea379fe 100644 --- a/predictor/predictor.go +++ b/predictor/predictor.go @@ -33,21 +33,11 @@ type Resource struct { knownGVK *schema.GroupVersionKind } -func NewResourceName(ctx context.Context, defaultAPICluster string) complete.Predictor { - // we don't want to error in our predictor so we just return an empty predictor - client, err := newClient(ctx, defaultAPICluster) - if err != nil { - return complete.PredictNothing - } +func NewResourceName(client *api.Client) complete.Predictor { return &Resource{client: client} } -func NewResourceNameWithKind(ctx context.Context, defaultAPICluster string, gvk schema.GroupVersionKind) complete.Predictor { - // we can't error in our predictor so we just return an empty predictor - client, err := newClient(ctx, defaultAPICluster) - if err != nil { - return complete.PredictNothing - } +func NewResourceNameWithKind(client *api.Client, gvk schema.GroupVersionKind) complete.Predictor { return &Resource{ client: client, knownGVK: ptr.To(gvk), @@ -109,7 +99,7 @@ func listKindToResource(kind string) string { return flect.Pluralize(strings.TrimSuffix(strings.ToLower(kind), listSuffix)) } -func newClient(ctx context.Context, defaultAPICluster string) (*api.Client, error) { +func NewClient(ctx context.Context, defaultAPICluster string) (*api.Client, error) { // the client for the predictor requires a static token in the client config // since dynamic exec config seems to break with some shells during completion. // The exact reason for that is unknown. diff --git a/update/application.go b/update/application.go index c1a7310a..90fb4c15 100644 --- a/update/application.go +++ b/update/application.go @@ -49,14 +49,14 @@ type applicationCmd struct { DeployJob *deployJob `embed:"" prefix:"deploy-job-"` WorkerJob *workerJob `embed:"" prefix:"worker-job-"` ScheduledJob *scheduledJob `embed:"" prefix:"scheduled-job-"` - DeleteWorkerJob *string `help:"Delete a worker job by name"` - DeleteScheduledJob *string `help:"Delete a scheduled job by name"` + DeleteWorkerJob *string `help:"Delete a worker job by name."` + DeleteScheduledJob *string `help:"Delete a scheduled job by name."` 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"` GitInformationServiceURL string `help:"URL of the git information service." default:"https://git-info.deplo.io" env:"GIT_INFORMATION_SERVICE_URL" hidden:""` - SkipRepoAccessCheck bool `help:"Skip the git repository access check" default:"false"` - Debug bool `help:"Enable debug messages" default:"false"` + SkipRepoAccessCheck bool `help:"Skip the git repository access check." default:"false"` + Debug bool `help:"Enable debug messages." default:"false"` Language *string `help:"${app_language_help} Possible values: ${enum}" enum:"ruby,php,python,golang,nodejs,static,"` DockerfileBuild dockerfileBuild `embed:""` } diff --git a/update/cloudvm.go b/update/cloudvm.go index 405e2661..dbd5a1e6 100644 --- a/update/cloudvm.go +++ b/update/cloudvm.go @@ -15,12 +15,12 @@ import ( type cloudVMCmd struct { resourceCmd - MachineType string `placeholder:"nine-standard-1" help:"The machine type defines the sizing for a particular CloudVM."` - Hostname string `placeholder:"" help:"Hostname allows to set the hostname explicitly. If unset, the name of the resource will be used as the hostname. This does not affect the DNS name."` - ReverseDNS string `placeholder:"" help:"Allows to set the reverse DNS of the CloudVM"` + MachineType string `placeholder:"nine-standard-1" help:"Defines the sizing for a particular CloudVM."` + Hostname string `placeholder:"" help:"Configures the hostname explicitly. If unset, the name of the resource will be used as the hostname. This does not affect the DNS name."` + ReverseDNS string `placeholder:"" help:"Allows to set the reverse DNS of the CloudVM."` OS string `placeholder:"ubuntu22.04" help:"OS which should be used to boot the VM."` BootDiskSize string `placeholder:"20Gi" help:"Configures the size of the boot disk."` - Disks map[string]string `placeholder:"{}" help:"Disks specifies which additional disks to mount to the machine."` + Disks map[string]string `placeholder:"{}" help:"Additional disks to mount to the machine."` On *bool `help:"Turns the CloudVM on."` Off *bool `help:"Turns the CloudVM off immediately."` Shutdown *bool `help:"Shuts down the CloudVM via ACPI."` diff --git a/update/keyvaluestore.go b/update/keyvaluestore.go index 12361478..74305baf 100644 --- a/update/keyvaluestore.go +++ b/update/keyvaluestore.go @@ -8,16 +8,18 @@ import ( meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - kresource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type keyValueStoreCmd struct { resourceCmd - MemorySize *string `help:"MemorySize configures KeyValueStore to use a specified amount of memory for the data set." placeholder:"1Gi"` - MaxMemoryPolicy *storage.KeyValueStoreMaxMemoryPolicy `help:"MaxMemoryPolicy specifies the exact behavior KeyValueStore follows when the maxmemory limit is reached." placeholder:"allkeys-lru"` - AllowedCidrs *[]meta.IPv4CIDR `help:"AllowedCIDRs specify the allowed IP addresses, connecting to the instance." placeholder:"203.0.113.1/32"` - PublicNetworkingEnabled *bool `help:"Specifies if the service should be available without service connection."` + MemorySize *storage.KeyValueStoreMemorySize `placeholder:"${keyvaluestore_memorysize_default}" help:"Available amount of memory."` + MaxMemoryPolicy *storage.KeyValueStoreMaxMemoryPolicy `placeholder:"${keyvaluestore_maxmemorypolicy_default}" help:"Behaviour when the memory limit is reached."` + AllowedCidrs *[]meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"IP addresses allowed to connect to the public endpoint."` + PublicNetworking *bool `negatable:"" help:"Enable or disable public networking."` + + // Deprecated Flags + PublicNetworkingEnabled *bool `hidden:"" help:"If public networking is \"false\", it is only possible to access the service by configuring a service connection."` } func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error { @@ -38,24 +40,23 @@ func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error }).Update(ctx) } -func (cmd *keyValueStoreCmd) applyUpdates(keyValueStore *storage.KeyValueStore) error { +func (cmd *keyValueStoreCmd) applyUpdates(kvs *storage.KeyValueStore) error { if cmd.MemorySize != nil { - q, err := kresource.ParseQuantity(*cmd.MemorySize) - if err != nil { - return fmt.Errorf("error parsing memory size %q: %w", *cmd.MemorySize, err) - } - - keyValueStore.Spec.ForProvider.MemorySize = &storage.KeyValueStoreMemorySize{Quantity: q} + kvs.Spec.ForProvider.MemorySize = cmd.MemorySize } if cmd.MaxMemoryPolicy != nil { - keyValueStore.Spec.ForProvider.MaxMemoryPolicy = *cmd.MaxMemoryPolicy + kvs.Spec.ForProvider.MaxMemoryPolicy = *cmd.MaxMemoryPolicy } if cmd.AllowedCidrs != nil { - keyValueStore.Spec.ForProvider.AllowedCIDRs = *cmd.AllowedCidrs + kvs.Spec.ForProvider.AllowedCIDRs = *cmd.AllowedCidrs } - if cmd.PublicNetworkingEnabled != nil { - keyValueStore.Spec.ForProvider.PublicNetworkingEnabled = cmd.PublicNetworkingEnabled + publicNetworking := cmd.PublicNetworking + if publicNetworking == nil { + publicNetworking = cmd.PublicNetworkingEnabled + } + if publicNetworking != nil { + kvs.Spec.ForProvider.PublicNetworkingEnabled = publicNetworking } return nil diff --git a/update/keyvaluestore_test.go b/update/keyvaluestore_test.go index 01f384e3..f6af7d91 100644 --- a/update/keyvaluestore_test.go +++ b/update/keyvaluestore_test.go @@ -2,7 +2,6 @@ package update import ( "context" - "reflect" "testing" meta "github.com/ninech/apis/meta/v1alpha1" @@ -28,22 +27,15 @@ func TestKeyValueStore(t *testing.T) { }, { name: "memorySize upgrade", - update: keyValueStoreCmd{MemorySize: ptr.To("1G")}, + update: keyValueStoreCmd{MemorySize: ptr.To(storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")})}, want: storage.KeyValueStoreParameters{MemorySize: memorySize("1G")}, }, { name: "memorySize downgrade", create: storage.KeyValueStoreParameters{MemorySize: memorySize("2G")}, - update: keyValueStoreCmd{MemorySize: ptr.To("1G")}, + update: keyValueStoreCmd{MemorySize: ptr.To(storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")})}, want: storage.KeyValueStoreParameters{MemorySize: memorySize("1G")}, }, - { - name: "invalid", - create: storage.KeyValueStoreParameters{MemorySize: memorySize("2G")}, - update: keyValueStoreCmd{MemorySize: ptr.To("invalid")}, - want: storage.KeyValueStoreParameters{MemorySize: memorySize("2G")}, - wantErr: true, - }, { name: "maxMemoryPolicy-to-noeviction", update: keyValueStoreCmd{ @@ -91,14 +83,14 @@ func TestKeyValueStore(t *testing.T) { create: storage.KeyValueStoreParameters{ AllowedCIDRs: []meta.IPv4CIDR{"0.0.0.0/0"}, }, - update: keyValueStoreCmd{MemorySize: ptr.To("1G")}, + update: keyValueStoreCmd{MemorySize: ptr.To(storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")})}, want: storage.KeyValueStoreParameters{ MemorySize: memorySize("1G"), AllowedCIDRs: []meta.IPv4CIDR{meta.IPv4CIDR("0.0.0.0/0")}, }, }, { - name: "update-public-networking", + name: "disable-public-networking-deprecated", create: storage.KeyValueStoreParameters{ PublicNetworkingEnabled: ptr.To(true), }, @@ -107,15 +99,37 @@ func TestKeyValueStore(t *testing.T) { PublicNetworkingEnabled: ptr.To(false), }, }, + { + name: "disable-public-networking", + create: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + update: keyValueStoreCmd{PublicNetworking: ptr.To(false)}, + want: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, + { + name: "disable-public-networking-both", + create: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + update: keyValueStoreCmd{PublicNetworking: ptr.To(false), PublicNetworkingEnabled: ptr.To(true)}, + want: storage.KeyValueStoreParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + is := require.New(t) + tt.update.Name = "test-" + t.Name() apiClient, err := test.SetupClient() - require.NoError(t, err) + is.NoError(err) - created := test.KeyValueStore(tt.update.Name, apiClient.Project, "nine-es34") + created := test.KeyValueStore(tt.update.Name, apiClient.Project, meta.LocationNineES34) created.Spec.ForProvider = tt.create if err := apiClient.Create(ctx, created); err != nil { t.Fatalf("keyvaluestore create error, got: %s", err) @@ -132,9 +146,7 @@ func TestKeyValueStore(t *testing.T) { t.Fatalf("expected keyvaluestore to exist, got: %s", err) } - if !reflect.DeepEqual(updated.Spec.ForProvider, tt.want) { - t.Fatalf("expected KeyValueStore.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) - } + is.Equal(tt.want, updated.Spec.ForProvider) }) } } diff --git a/update/mysql.go b/update/mysql.go index b266b00f..7406450e 100644 --- a/update/mysql.go +++ b/update/mysql.go @@ -3,22 +3,23 @@ package update import ( "context" "fmt" + "os" "github.com/crossplane/crossplane-runtime/pkg/resource" infra "github.com/ninech/apis/infrastructure/v1alpha1" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/file" + "github.com/ninech/nctl/create" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type mySQLCmd struct { resourceCmd MachineType *string `placeholder:"${mysql_machine_default}" help:"Defines the sizing for a particular MySQL instance. Available types: ${mysql_machine_types}"` - AllowedCidrs *[]meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"Specifies the IP addresses allowed to connect to the instance." ` - SSHKeys []storage.SSHKey `help:"Contains a list of SSH public keys, allowed to connect to the db server, in order to up-/download and directly restore database backups."` - SSHKeysFile string `help:"Path to a file containing a list of SSH public keys (see above), separated by newlines."` + AllowedCidrs *[]meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"Specifies the IP addresses allowed to connect to the instance."` + SSHKeys []storage.SSHKey `help:"SSH public keys allowed to connect to the database server in order to up-/download and directly restore database backups."` + SSHKeysFile *os.File `predictor:"file" help:"Path to a file containing a list of SSH public keys (see above), separated by newlines. Lines prefixed with # are ignored."` SQLMode *[]storage.MySQLMode `placeholder:"\"MODE1, MODE2, ...\"" help:"Configures the sql_mode setting. Modes affect the SQL syntax MySQL supports and the data validation checks it performs. Defaults to: ${mysql_mode}"` CharacterSetName *string `placeholder:"${mysql_charset}" help:"Configures the character_set_server variable."` CharacterSetCollation *string `placeholder:"${mysql_collation}" help:"Configures the collation_server variable."` @@ -42,11 +43,15 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error { return fmt.Errorf("resource is of type %T, expected %T", current, storage.MySQL{}) } - sshkeys, err := file.ReadSSHKeys(cmd.SSHKeysFile) - if err != nil { - return fmt.Errorf("error when reading SSH keys file: %w", err) + if cmd.SSHKeysFile != nil { + defer cmd.SSHKeysFile.Close() + + keys, err := create.ParseSSHKeys(cmd.SSHKeysFile) + if err != nil { + return err + } + cmd.SSHKeys = keys } - cmd.SSHKeys = append(cmd.SSHKeys, sshkeys...) cmd.applyUpdates(mysql) return nil diff --git a/update/opensearch.go b/update/opensearch.go index 98be1fa1..f2e2aa43 100644 --- a/update/opensearch.go +++ b/update/opensearch.go @@ -9,21 +9,22 @@ import ( meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/create" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type openSearchCmd struct { resourceCmd - MachineType *string `help:"MachineType configures OpenSearch to use a specified machine type." placeholder:"nine-search-m"` - AllowedCidrs *[]meta.IPv4CIDR `help:"AllowedCIDRs specify the allowed IP addresses, connecting to the cluster." placeholder:"203.0.113.1/32"` - BucketUsers *[]string `help:"BucketUsers specify the users who have read access to the OpenSearch bucket." placeholder:"user1,user2"` + MachineType *string `help:"Configures OpenSearch to use a specified machine type." placeholder:"nine-search-m"` + AllowedCidrs *[]meta.IPv4CIDR `help:"IP addresses allowed to connect to the cluster. These restrictions do not apply for service connections." placeholder:"203.0.113.1/32"` + BucketUsers *[]create.LocalReference `help:"Users who have read access to the OpenSearch snapshots bucket." placeholder:"user1,user2"` + PublicNetworking *bool `negatable:"" help:"Enable or disable public networking."` + + // Deprecated Flags + PublicNetworkingEnabled *bool `hidden:"" help:"If public networking is \"false\", it is only possible to access the service by configuring a service connection."` } func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { - if cmd.MachineType == nil && cmd.AllowedCidrs == nil && cmd.BucketUsers == nil { - return fmt.Errorf("at least one parameter must be provided to update the OpenSearch cluster") - } - openSearch := &storage.OpenSearch{ ObjectMeta: metav1.ObjectMeta{ Name: cmd.Name, @@ -41,19 +42,28 @@ func (cmd *openSearchCmd) Run(ctx context.Context, client *api.Client) error { }).Update(ctx) } -func (cmd *openSearchCmd) applyUpdates(openSearch *storage.OpenSearch) error { +func (cmd *openSearchCmd) applyUpdates(os *storage.OpenSearch) error { if cmd.MachineType != nil { - openSearch.Spec.ForProvider.MachineType = infra.NewMachineType(*cmd.MachineType) + os.Spec.ForProvider.MachineType = infra.NewMachineType(*cmd.MachineType) } if cmd.AllowedCidrs != nil { - openSearch.Spec.ForProvider.AllowedCIDRs = *cmd.AllowedCidrs + os.Spec.ForProvider.AllowedCIDRs = *cmd.AllowedCidrs } if cmd.BucketUsers != nil { - bucketUsers := make([]meta.LocalReference, len(*cmd.BucketUsers)) - for i, user := range *cmd.BucketUsers { - bucketUsers[i] = meta.LocalReference{Name: user} + bucketUsers := make([]meta.LocalReference, 0, len(*cmd.BucketUsers)) + for _, user := range *cmd.BucketUsers { + bucketUsers = append(bucketUsers, user.LocalReference) } - openSearch.Spec.ForProvider.BucketUsers = bucketUsers + + os.Spec.ForProvider.BucketUsers = bucketUsers + } + + publicNetworking := cmd.PublicNetworking + if publicNetworking == nil { + publicNetworking = cmd.PublicNetworkingEnabled + } + if publicNetworking != nil { + os.Spec.ForProvider.PublicNetworkingEnabled = publicNetworking } return nil diff --git a/update/opensearch_test.go b/update/opensearch_test.go index af5f202c..caa673ae 100644 --- a/update/opensearch_test.go +++ b/update/opensearch_test.go @@ -4,11 +4,11 @@ import ( "context" "testing" - "github.com/google/go-cmp/cmp" infra "github.com/ninech/apis/infrastructure/v1alpha1" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/create" "github.com/ninech/nctl/internal/test" "github.com/stretchr/testify/require" "k8s.io/utils/ptr" @@ -25,6 +25,7 @@ func TestOpenSearch(t *testing.T) { }{ { name: "increase-machineType", + create: storage.OpenSearchParameters{MachineType: infra.MachineTypeNineSearchS}, update: openSearchCmd{MachineType: ptr.To(infra.MachineTypeNineSearchL.String())}, want: storage.OpenSearchParameters{MachineType: infra.MachineTypeNineSearchL}, }, @@ -55,21 +56,56 @@ func TestOpenSearch(t *testing.T) { }, }, { - name: "bucket-users-set", - update: openSearchCmd{BucketUsers: &[]string{"user1", "user2"}}, + name: "bucket-users-set", + update: openSearchCmd{BucketUsers: &[]create.LocalReference{ + {LocalReference: meta.LocalReference{Name: "user1"}}, + {LocalReference: meta.LocalReference{Name: "user2"}}, + }}, want: storage.OpenSearchParameters{ BucketUsers: []meta.LocalReference{{Name: "user1"}, {Name: "user2"}}, }, }, + { + name: "disable-public-networking-deprecated", + create: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + update: openSearchCmd{PublicNetworkingEnabled: ptr.To(false)}, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, + { + name: "disable-public-networking", + create: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + update: openSearchCmd{PublicNetworking: ptr.To(false)}, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, + { + name: "disable-public-networking-both", + create: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(true), + }, + update: openSearchCmd{PublicNetworking: ptr.To(false), PublicNetworkingEnabled: ptr.To(true)}, + want: storage.OpenSearchParameters{ + PublicNetworkingEnabled: ptr.To(false), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + is := require.New(t) + tt.update.Name = "test-" + t.Name() apiClient, err := test.SetupClient() - require.NoError(t, err) + is.NoError(err) - created := test.OpenSearch(tt.update.Name, apiClient.Project, "nine-es34") + created := test.OpenSearch(tt.update.Name, apiClient.Project, meta.LocationNineES34) created.Spec.ForProvider = tt.create if err := apiClient.Create(ctx, created); err != nil { t.Fatalf("opensearch create error, got: %s", err) @@ -86,9 +122,7 @@ func TestOpenSearch(t *testing.T) { t.Fatalf("expected openSearch to exist, got: %s", err) } - if !cmp.Equal(updated.Spec.ForProvider, tt.want) { - t.Fatalf("expected openSearch.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) - } + is.EqualExportedValues(tt.want, updated.Spec.ForProvider) // As machine types contain unexported values. }) } } diff --git a/update/postgres.go b/update/postgres.go index 38df6b5c..a632eddd 100644 --- a/update/postgres.go +++ b/update/postgres.go @@ -3,22 +3,23 @@ package update import ( "context" "fmt" + "os" "github.com/crossplane/crossplane-runtime/pkg/resource" infra "github.com/ninech/apis/infrastructure/v1alpha1" meta "github.com/ninech/apis/meta/v1alpha1" storage "github.com/ninech/apis/storage/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/internal/file" + "github.com/ninech/nctl/create" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type postgresCmd struct { resourceCmd MachineType *string `placeholder:"${postgres_machine_default}" help:"Defines the sizing for a particular PostgreSQL instance. Available types: ${postgres_machine_types}"` - AllowedCidrs *[]meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"Specifies the IP addresses allowed to connect to the instance." ` - SSHKeys []storage.SSHKey `help:"Contains a list of SSH public keys, allowed to connect to the db server, in order to up-/download and directly restore database backups."` - SSHKeysFile string `help:"Path to a file containing a list of SSH public keys (see above), separated by newlines."` + AllowedCidrs *[]meta.IPv4CIDR `placeholder:"203.0.113.1/32" help:"Specifies the IP addresses allowed to connect to the instance."` + SSHKeys []storage.SSHKey `help:"SSH public keys allowed to connect to the database server in order to up-/download and directly restore database backups."` + SSHKeysFile *os.File `predictor:"file" help:"Path to a file containing a list of SSH public keys (see above), separated by newlines. Lines prefixed with # are ignored."` KeepDailyBackups *int `placeholder:"${postgres_backup_retention_days}" help:"Number of daily database backups to keep. Note that setting this to 0, backup will be disabled and existing dumps deleted immediately."` } @@ -36,11 +37,15 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { return fmt.Errorf("resource is of type %T, expected %T", current, storage.Postgres{}) } - sshkeys, err := file.ReadSSHKeys(cmd.SSHKeysFile) - if err != nil { - return fmt.Errorf("error when reading SSH keys file: %w", err) + if cmd.SSHKeysFile != nil { + defer cmd.SSHKeysFile.Close() + + keys, err := create.ParseSSHKeys(cmd.SSHKeysFile) + if err != nil { + return err + } + cmd.SSHKeys = keys } - cmd.SSHKeys = append(cmd.SSHKeys, sshkeys...) cmd.applyUpdates(postgres) return nil