diff --git a/Makefile b/Makefile index ca48197c..aa3e1594 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,10 @@ manifests: cozypkg: go build -ldflags "-X github.com/cozystack/cozystack/cmd/cozypkg/cmd.Version=v$(COZYSTACK_VERSION)" -o _out/bin/cozypkg ./cmd/cozypkg -assets: assets-talos assets-cozypkg +cozyctl: + go build -ldflags "-X github.com/cozystack/cozystack/cmd/cozyctl/cmd.Version=v$(COZYSTACK_VERSION)" -o _out/bin/cozyctl ./cmd/cozyctl + +assets: assets-talos assets-cozypkg assets-cozyctl assets-talos: make -C packages/core/talos assets @@ -73,6 +76,16 @@ assets-cozypkg-%: cp LICENSE _out/bin/cozypkg-$*/LICENSE tar -C _out/bin/cozypkg-$* -czf _out/assets/cozypkg-$*.tar.gz LICENSE cozypkg$(EXT) +assets-cozyctl: assets-cozyctl-linux-amd64 assets-cozyctl-linux-arm64 assets-cozyctl-darwin-amd64 assets-cozyctl-darwin-arm64 assets-cozyctl-windows-amd64 assets-cozyctl-windows-arm64 + (cd _out/assets/ && sha256sum cozyctl-*.tar.gz) > _out/assets/cozyctl-checksums.txt + +assets-cozyctl-%: + $(eval EXT := $(if $(filter windows,$(firstword $(subst -, ,$*))),.exe,)) + mkdir -p _out/assets + GOOS=$(firstword $(subst -, ,$*)) GOARCH=$(lastword $(subst -, ,$*)) go build -ldflags "-X github.com/cozystack/cozystack/cmd/cozyctl/cmd.Version=v$(COZYSTACK_VERSION)" -o _out/bin/cozyctl-$*/cozyctl$(EXT) ./cmd/cozyctl + cp LICENSE _out/bin/cozyctl-$*/LICENSE + tar -C _out/bin/cozyctl-$* -czf _out/assets/cozyctl-$*.tar.gz LICENSE cozyctl$(EXT) + test: make -C packages/core/testing apply make -C packages/core/testing test diff --git a/cmd/cozyctl/cmd/client.go b/cmd/cozyctl/cmd/client.go new file mode 100644 index 00000000..948449f7 --- /dev/null +++ b/cmd/cozyctl/cmd/client.go @@ -0,0 +1,114 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + + cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/dynamic" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func buildRestConfig() (*rest.Config, error) { + rules := clientcmd.NewDefaultClientConfigLoadingRules() + if globalFlags.kubeconfig != "" { + rules.ExplicitPath = globalFlags.kubeconfig + } + overrides := &clientcmd.ConfigOverrides{} + if globalFlags.context != "" { + overrides.CurrentContext = globalFlags.context + } + config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides).ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + return config, nil +} + +func newScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(cozyv1alpha1.AddToScheme(scheme)) + return scheme +} + +func newClients() (client.Client, dynamic.Interface, error) { + config, err := buildRestConfig() + if err != nil { + return nil, nil, err + } + + scheme := newScheme() + + typedClient, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return nil, nil, fmt.Errorf("failed to create k8s client: %w", err) + } + + dynClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + + return typedClient, dynClient, nil +} + +func getNamespace() (string, error) { + if globalFlags.namespace != "" { + return globalFlags.namespace, nil + } + + rules := clientcmd.NewDefaultClientConfigLoadingRules() + if globalFlags.kubeconfig != "" { + rules.ExplicitPath = globalFlags.kubeconfig + } + overrides := &clientcmd.ConfigOverrides{} + if globalFlags.context != "" { + overrides.CurrentContext = globalFlags.context + } + + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides) + ns, _, err := clientConfig.Namespace() + if err != nil { + return "", fmt.Errorf("failed to determine namespace: %w", err) + } + if ns == "" { + ns = "default" + } + return ns, nil +} + +// getRestConfig is a convenience function when only the rest.Config is needed +// (used by buildRestConfig but also available for other callers). +func getRestConfig() (*rest.Config, error) { + if globalFlags.kubeconfig != "" || globalFlags.context != "" { + return buildRestConfig() + } + config, err := ctrl.GetConfig() + if err != nil { + return nil, fmt.Errorf("failed to get kubeconfig: %w", err) + } + return config, nil +} diff --git a/cmd/cozyctl/cmd/console.go b/cmd/cozyctl/cmd/console.go new file mode 100644 index 00000000..a5f839c9 --- /dev/null +++ b/cmd/cozyctl/cmd/console.go @@ -0,0 +1,43 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var consoleCmd = &cobra.Command{ + Use: "console ", + Short: "Open a serial console to a VirtualMachine", + Long: `Open a serial console to a VirtualMachine using virtctl. Only valid for VirtualMachine or VMInstance kinds.`, + Args: cobra.ExactArgs(2), + RunE: runConsole, +} + +func init() { + rootCmd.AddCommand(consoleCmd) +} + +func runConsole(cmd *cobra.Command, args []string) error { + vmName, ns, err := resolveVMArgs(args) + if err != nil { + return err + } + + virtctlArgs := []string{"virtctl", "console", vmName, "-n", ns} + return execVirtctl(virtctlArgs) +} diff --git a/cmd/cozyctl/cmd/discovery.go b/cmd/cozyctl/cmd/discovery.go new file mode 100644 index 00000000..4a6cb5d0 --- /dev/null +++ b/cmd/cozyctl/cmd/discovery.go @@ -0,0 +1,112 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "context" + "fmt" + "strings" + + cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// AppDefInfo holds resolved information about an ApplicationDefinition. +type AppDefInfo struct { + Name string // e.g. "postgres" + Kind string // e.g. "Postgres" + Plural string // e.g. "postgreses" + Singular string // e.g. "postgres" + Prefix string // e.g. "postgres-" + IsModule bool +} + +// AppDefRegistry provides fast lookup of ApplicationDefinitions by plural, singular, or kind. +type AppDefRegistry struct { + byPlural map[string]*AppDefInfo + bySingular map[string]*AppDefInfo + byKind map[string]*AppDefInfo + all []*AppDefInfo +} + +// discoverAppDefs lists all ApplicationDefinitions from the cluster and builds a registry. +func discoverAppDefs(ctx context.Context, typedClient client.Client) (*AppDefRegistry, error) { + var list cozyv1alpha1.ApplicationDefinitionList + if err := typedClient.List(ctx, &list); err != nil { + return nil, fmt.Errorf("failed to list ApplicationDefinitions: %w", err) + } + + reg := &AppDefRegistry{ + byPlural: make(map[string]*AppDefInfo), + bySingular: make(map[string]*AppDefInfo), + byKind: make(map[string]*AppDefInfo), + } + + for i := range list.Items { + ad := &list.Items[i] + info := &AppDefInfo{ + Name: ad.Name, + Kind: ad.Spec.Application.Kind, + Plural: ad.Spec.Application.Plural, + Singular: ad.Spec.Application.Singular, + Prefix: ad.Spec.Release.Prefix, + IsModule: ad.Spec.Dashboard != nil && ad.Spec.Dashboard.Module, + } + reg.all = append(reg.all, info) + reg.byPlural[strings.ToLower(info.Plural)] = info + reg.bySingular[strings.ToLower(info.Singular)] = info + reg.byKind[strings.ToLower(info.Kind)] = info + } + + return reg, nil +} + +// Resolve looks up an AppDefInfo by name (case-insensitive), checking plural, singular, then kind. +func (r *AppDefRegistry) Resolve(name string) *AppDefInfo { + lower := strings.ToLower(name) + if info, ok := r.byPlural[lower]; ok { + return info + } + if info, ok := r.bySingular[lower]; ok { + return info + } + if info, ok := r.byKind[lower]; ok { + return info + } + return nil +} + +// ResolveModule looks up an AppDefInfo among modules only. +func (r *AppDefRegistry) ResolveModule(name string) *AppDefInfo { + lower := strings.ToLower(name) + for _, info := range r.all { + if !info.IsModule { + continue + } + if strings.ToLower(info.Plural) == lower || + strings.ToLower(info.Singular) == lower || + strings.ToLower(info.Kind) == lower { + return info + } + } + return nil +} + +// All returns all discovered AppDefInfo entries. +func (r *AppDefRegistry) All() []*AppDefInfo { + return r.all +} diff --git a/cmd/cozyctl/cmd/get.go b/cmd/cozyctl/cmd/get.go new file mode 100644 index 00000000..1d57ac6b --- /dev/null +++ b/cmd/cozyctl/cmd/get.go @@ -0,0 +1,361 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1" + corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var getCmdFlags struct { + target string +} + +var getCmd = &cobra.Command{ + Use: "get [name]", + Short: "Display one or many resources", + Long: `Display one or many resources. + +Built-in types: + ns, namespaces Tenant namespaces (cluster-scoped) + modules Tenant modules + pvc, pvcs PersistentVolumeClaims + +Sub-resource types (use -t to filter by parent application): + secrets Secrets + services, svc Services + ingresses, ing Ingresses + workloads WorkloadMonitors + +Application types are discovered dynamically from ApplicationDefinitions. +Use -t type/name to filter sub-resources by a specific application.`, + Args: cobra.RangeArgs(1, 2), + RunE: runGet, +} + +func init() { + rootCmd.AddCommand(getCmd) + getCmd.Flags().StringVarP(&getCmdFlags.target, "target", "t", "", "Filter sub-resources by application type/name") +} + +func runGet(cmd *cobra.Command, args []string) error { + ctx := context.Background() + resourceType := args[0] + var resourceName string + if len(args) > 1 { + resourceName = args[1] + } + + switch strings.ToLower(resourceType) { + case "ns", "namespace", "namespaces": + return getNamespaces(ctx, resourceName) + case "module", "modules": + return getModules(ctx, resourceName) + case "pvc", "pvcs", "persistentvolumeclaim", "persistentvolumeclaims": + return getPVCs(ctx, resourceName) + case "secret", "secrets": + return getSubResources(ctx, "secrets", resourceName) + case "service", "services", "svc": + return getSubResources(ctx, "services", resourceName) + case "ingress", "ingresses", "ing": + return getSubResources(ctx, "ingresses", resourceName) + case "workload", "workloads": + return getSubResources(ctx, "workloads", resourceName) + default: + return getApplications(ctx, resourceType, resourceName) + } +} + +func getNamespaces(ctx context.Context, name string) error { + _, dynClient, err := newClients() + if err != nil { + return err + } + + gvr := schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"} + + if name != "" { + item, err := dynClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get namespace %q: %w", name, err) + } + printNamespaces([]unstructured.Unstructured{*item}) + return nil + } + + list, err := dynClient.Resource(gvr).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list namespaces: %w", err) + } + if len(list.Items) == 0 { + printNoResources(os.Stderr, "namespaces") + return nil + } + printNamespaces(list.Items) + return nil +} + +func getModules(ctx context.Context, name string) error { + _, dynClient, err := newClients() + if err != nil { + return err + } + + ns, err := getNamespace() + if err != nil { + return err + } + + gvr := schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantmodules"} + + if name != "" { + item, err := dynClient.Resource(gvr).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get module %q: %w", name, err) + } + printModules([]unstructured.Unstructured{*item}) + return nil + } + + list, err := dynClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list modules: %w", err) + } + if len(list.Items) == 0 { + printNoResources(os.Stderr, "modules") + return nil + } + printModules(list.Items) + return nil +} + +func getPVCs(ctx context.Context, name string) error { + _, dynClient, err := newClients() + if err != nil { + return err + } + + ns, err := getNamespace() + if err != nil { + return err + } + + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumeclaims"} + + if name != "" { + item, err := dynClient.Resource(gvr).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get PVC %q: %w", name, err) + } + printPVCs([]unstructured.Unstructured{*item}) + return nil + } + + list, err := dynClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list PVCs: %w", err) + } + if len(list.Items) == 0 { + printNoResources(os.Stderr, "PVCs") + return nil + } + printPVCs(list.Items) + return nil +} + +func getSubResources(ctx context.Context, subType string, name string) error { + typedClient, dynClient, err := newClients() + if err != nil { + return err + } + + ns, err := getNamespace() + if err != nil { + return err + } + + labelSelector, err := buildSubResourceSelector(ctx, typedClient, getCmdFlags.target) + if err != nil { + return err + } + + switch subType { + case "secrets": + return getFilteredSecrets(ctx, dynClient, ns, name, labelSelector) + case "services": + return getFilteredServices(ctx, dynClient, ns, name, labelSelector) + case "ingresses": + return getFilteredIngresses(ctx, dynClient, ns, name, labelSelector) + case "workloads": + return getFilteredWorkloads(ctx, dynClient, ns, name, labelSelector) + default: + return fmt.Errorf("unknown sub-resource type: %s", subType) + } +} + +func buildSubResourceSelector(ctx context.Context, typedClient client.Client, target string) (string, error) { + var selectors []string + + if target == "" { + selectors = append(selectors, corev1alpha1.TenantResourceLabelKey+"="+corev1alpha1.TenantResourceLabelValue) + return strings.Join(selectors, ","), nil + } + + parts := strings.SplitN(target, "/", 2) + if len(parts) != 2 { + return "", fmt.Errorf("invalid target format %q, expected type/name", target) + } + targetType, targetName := parts[0], parts[1] + + // Discover ApplicationDefinitions to resolve the target type + registry, err := discoverAppDefs(ctx, typedClient) + if err != nil { + return "", err + } + + // Check if this is a module reference + if strings.ToLower(targetType) == "module" { + info := registry.ResolveModule(targetName) + if info == nil { + return "", fmt.Errorf("unknown module %q", targetName) + } + selectors = append(selectors, + appsv1alpha1.ApplicationKindLabel+"="+info.Kind, + appsv1alpha1.ApplicationNameLabel+"="+targetName, + corev1alpha1.TenantResourceLabelKey+"="+corev1alpha1.TenantResourceLabelValue, + ) + return strings.Join(selectors, ","), nil + } + + info := registry.Resolve(targetType) + if info == nil { + return "", fmt.Errorf("unknown application type %q", targetType) + } + + selectors = append(selectors, + appsv1alpha1.ApplicationKindLabel+"="+info.Kind, + appsv1alpha1.ApplicationNameLabel+"="+targetName, + corev1alpha1.TenantResourceLabelKey+"="+corev1alpha1.TenantResourceLabelValue, + ) + return strings.Join(selectors, ","), nil +} + +func getFilteredSecrets(ctx context.Context, dynClient dynamic.Interface, ns, name, labelSelector string) error { + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"} + return getFilteredResources(ctx, dynClient, gvr, ns, name, labelSelector, "secrets", printSecrets) +} + +func getFilteredServices(ctx context.Context, dynClient dynamic.Interface, ns, name, labelSelector string) error { + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "services"} + return getFilteredResources(ctx, dynClient, gvr, ns, name, labelSelector, "services", printServices) +} + +func getFilteredIngresses(ctx context.Context, dynClient dynamic.Interface, ns, name, labelSelector string) error { + gvr := schema.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"} + return getFilteredResources(ctx, dynClient, gvr, ns, name, labelSelector, "ingresses", printIngresses) +} + +func getFilteredWorkloads(ctx context.Context, dynClient dynamic.Interface, ns, name, labelSelector string) error { + gvr := schema.GroupVersionResource{Group: "cozystack.io", Version: "v1alpha1", Resource: "workloadmonitors"} + return getFilteredResources(ctx, dynClient, gvr, ns, name, labelSelector, "workloads", printWorkloads) +} + +func getFilteredResources( + ctx context.Context, + dynClient dynamic.Interface, + gvr schema.GroupVersionResource, + ns, name, labelSelector string, + typeName string, + printer func([]unstructured.Unstructured), +) error { + if name != "" { + item, err := dynClient.Resource(gvr).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get %s %q: %w", typeName, name, err) + } + printer([]unstructured.Unstructured{*item}) + return nil + } + + list, err := dynClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + return fmt.Errorf("failed to list %s: %w", typeName, err) + } + if len(list.Items) == 0 { + printNoResources(os.Stderr, typeName) + return nil + } + printer(list.Items) + return nil +} + +func getApplications(ctx context.Context, resourceType, name string) error { + typedClient, dynClient, err := newClients() + if err != nil { + return err + } + + ns, err := getNamespace() + if err != nil { + return err + } + + registry, err := discoverAppDefs(ctx, typedClient) + if err != nil { + return err + } + + info := registry.Resolve(resourceType) + if info == nil { + return fmt.Errorf("unknown resource type %q\nUse 'cozyctl get --help' for available types", resourceType) + } + + gvr := schema.GroupVersionResource{Group: "apps.cozystack.io", Version: "v1alpha1", Resource: info.Plural} + + if name != "" { + item, err := dynClient.Resource(gvr).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get %s %q: %w", info.Singular, name, err) + } + printApplications([]unstructured.Unstructured{*item}) + return nil + } + + list, err := dynClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list %s: %w", info.Plural, err) + } + if len(list.Items) == 0 { + printNoResources(os.Stderr, info.Plural) + return nil + } + printApplications(list.Items) + return nil +} diff --git a/cmd/cozyctl/cmd/migrate.go b/cmd/cozyctl/cmd/migrate.go new file mode 100644 index 00000000..a3148c69 --- /dev/null +++ b/cmd/cozyctl/cmd/migrate.go @@ -0,0 +1,43 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var migrateCmd = &cobra.Command{ + Use: "migrate ", + Short: "Live-migrate a VirtualMachine to another node", + Long: `Live-migrate a VirtualMachine to another node using virtctl. Only valid for VirtualMachine or VMInstance kinds.`, + Args: cobra.ExactArgs(2), + RunE: runMigrate, +} + +func init() { + rootCmd.AddCommand(migrateCmd) +} + +func runMigrate(cmd *cobra.Command, args []string) error { + vmName, ns, err := resolveVMArgs(args) + if err != nil { + return err + } + + virtctlArgs := []string{"virtctl", "migrate", vmName, "-n", ns} + return execVirtctl(virtctlArgs) +} diff --git a/cmd/cozyctl/cmd/portforward.go b/cmd/cozyctl/cmd/portforward.go new file mode 100644 index 00000000..19fc3081 --- /dev/null +++ b/cmd/cozyctl/cmd/portforward.go @@ -0,0 +1,51 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var portForwardCmd = &cobra.Command{ + Use: "port-forward [ports...]", + Short: "Forward ports to a VirtualMachineInstance", + Long: `Forward ports to a VirtualMachineInstance using virtctl. Only valid for VirtualMachine or VMInstance kinds.`, + Args: cobra.MinimumNArgs(2), + RunE: runPortForward, +} + +func init() { + rootCmd.AddCommand(portForwardCmd) +} + +func runPortForward(cmd *cobra.Command, args []string) error { + vmName, ns, err := resolveVMArgs(args[:1]) + if err != nil { + return err + } + + ports := args[1:] + if len(ports) == 0 { + return fmt.Errorf("at least one port is required") + } + + virtctlArgs := []string{"virtctl", "port-forward", "vmi/" + vmName, "-n", ns} + virtctlArgs = append(virtctlArgs, ports...) + return execVirtctl(virtctlArgs) +} diff --git a/cmd/cozyctl/cmd/printer.go b/cmd/cozyctl/cmd/printer.go new file mode 100644 index 00000000..c8020504 --- /dev/null +++ b/cmd/cozyctl/cmd/printer.go @@ -0,0 +1,250 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) +} + +func printApplications(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME\tVERSION\tREADY\tSTATUS") + for _, item := range items { + name := item.GetName() + version, _, _ := unstructured.NestedString(item.Object, "appVersion") + ready, status := extractCondition(item) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, version, ready, truncate(status, 48)) + } +} + +func printNamespaces(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME") + for _, item := range items { + fmt.Fprintln(w, item.GetName()) + } +} + +func printModules(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME\tVERSION\tREADY\tSTATUS") + for _, item := range items { + name := item.GetName() + version, _, _ := unstructured.NestedString(item.Object, "appVersion") + ready, status := extractCondition(item) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, version, ready, truncate(status, 48)) + } +} + +func printPVCs(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME\tSTATUS\tVOLUME\tCAPACITY\tSTORAGECLASS") + for _, item := range items { + name := item.GetName() + phase, _, _ := unstructured.NestedString(item.Object, "status", "phase") + volume, _, _ := unstructured.NestedString(item.Object, "spec", "volumeName") + capacity := "" + if cap, ok, _ := unstructured.NestedStringMap(item.Object, "status", "capacity"); ok { + capacity = cap["storage"] + } + sc, _, _ := unstructured.NestedString(item.Object, "spec", "storageClassName") + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, phase, volume, capacity, sc) + } +} + +func printSecrets(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME\tTYPE\tDATA") + for _, item := range items { + name := item.GetName() + secretType, _, _ := unstructured.NestedString(item.Object, "type") + data, _, _ := unstructured.NestedMap(item.Object, "data") + fmt.Fprintf(w, "%s\t%s\t%d\n", name, secretType, len(data)) + } +} + +func printServices(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME\tTYPE\tCLUSTER-IP\tEXTERNAL-IP\tPORTS") + for _, item := range items { + name := item.GetName() + svcType, _, _ := unstructured.NestedString(item.Object, "spec", "type") + clusterIP, _, _ := unstructured.NestedString(item.Object, "spec", "clusterIP") + + externalIP := "" + if lbIngress, ok, _ := unstructured.NestedSlice(item.Object, "status", "loadBalancer", "ingress"); ok && len(lbIngress) > 0 { + var ips []string + for _, ingress := range lbIngress { + if m, ok := ingress.(map[string]interface{}); ok { + if ip, ok := m["ip"].(string); ok { + ips = append(ips, ip) + } else if hostname, ok := m["hostname"].(string); ok { + ips = append(ips, hostname) + } + } + } + if len(ips) > 0 { + externalIP = strings.Join(ips, ",") + } + } + + ports := formatPorts(item) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, svcType, clusterIP, externalIP, ports) + } +} + +func printIngresses(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME\tCLASS\tHOSTS\tADDRESS") + for _, item := range items { + name := item.GetName() + class, _, _ := unstructured.NestedString(item.Object, "spec", "ingressClassName") + + var hosts []string + if rules, ok, _ := unstructured.NestedSlice(item.Object, "spec", "rules"); ok { + for _, rule := range rules { + if m, ok := rule.(map[string]interface{}); ok { + if host, ok := m["host"].(string); ok { + hosts = append(hosts, host) + } + } + } + } + hostsStr := "" + if len(hosts) > 0 { + hostsStr = strings.Join(hosts, ",") + } + + address := "" + if lbIngress, ok, _ := unstructured.NestedSlice(item.Object, "status", "loadBalancer", "ingress"); ok && len(lbIngress) > 0 { + var addrs []string + for _, ingress := range lbIngress { + if m, ok := ingress.(map[string]interface{}); ok { + if ip, ok := m["ip"].(string); ok { + addrs = append(addrs, ip) + } else if hostname, ok := m["hostname"].(string); ok { + addrs = append(addrs, hostname) + } + } + } + address = strings.Join(addrs, ",") + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, class, hostsStr, address) + } +} + +func printWorkloads(items []unstructured.Unstructured) { + w := newTabWriter() + defer w.Flush() + + fmt.Fprintln(w, "NAME\tKIND\tTYPE\tVERSION\tAVAILABLE\tOBSERVED\tOPERATIONAL") + for _, item := range items { + name := item.GetName() + kind, _, _ := unstructured.NestedString(item.Object, "spec", "kind") + wType, _, _ := unstructured.NestedString(item.Object, "spec", "type") + version, _, _ := unstructured.NestedString(item.Object, "spec", "version") + available, _, _ := unstructured.NestedInt64(item.Object, "status", "availableReplicas") + observed, _, _ := unstructured.NestedInt64(item.Object, "status", "observedReplicas") + operational, ok, _ := unstructured.NestedBool(item.Object, "status", "operational") + opStr := "" + if ok { + opStr = fmt.Sprintf("%t", operational) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%d\t%s\n", name, kind, wType, version, available, observed, opStr) + } +} + +func printNoResources(w io.Writer, resourceType string) { + fmt.Fprintf(w, "No %s found\n", resourceType) +} + +func extractCondition(item unstructured.Unstructured) (string, string) { + conditions, ok, _ := unstructured.NestedSlice(item.Object, "status", "conditions") + if !ok { + return "Unknown", "" + } + for _, c := range conditions { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + if cond["type"] == "Ready" { + ready, _ := cond["status"].(string) + message, _ := cond["message"].(string) + return ready, message + } + } + return "Unknown", "" +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +func formatPorts(item unstructured.Unstructured) string { + ports, ok, _ := unstructured.NestedSlice(item.Object, "spec", "ports") + if !ok || len(ports) == 0 { + return "" + } + var parts []string + for _, p := range ports { + port, ok := p.(map[string]interface{}) + if !ok { + continue + } + portNum, _, _ := unstructured.NestedInt64(port, "port") + protocol, _, _ := unstructured.NestedString(port, "protocol") + if protocol == "" { + protocol = "TCP" + } + nodePort, _, _ := unstructured.NestedInt64(port, "nodePort") + if nodePort > 0 { + parts = append(parts, fmt.Sprintf("%d:%d/%s", portNum, nodePort, protocol)) + } else { + parts = append(parts, fmt.Sprintf("%d/%s", portNum, protocol)) + } + } + return strings.Join(parts, ",") +} diff --git a/cmd/cozyctl/cmd/root.go b/cmd/cozyctl/cmd/root.go new file mode 100644 index 00000000..8cb2889a --- /dev/null +++ b/cmd/cozyctl/cmd/root.go @@ -0,0 +1,57 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// Version is set at build time via -ldflags. +var Version = "dev" + +var globalFlags struct { + kubeconfig string + context string + namespace string +} + +var rootCmd = &cobra.Command{ + Use: "cozyctl", + Short: "A CLI for managing Cozystack applications", + SilenceErrors: true, + SilenceUsage: true, + DisableAutoGenTag: true, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() error { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return err + } + return nil +} + +func init() { + rootCmd.Version = Version + rootCmd.PersistentFlags().StringVar(&globalFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file") + rootCmd.PersistentFlags().StringVar(&globalFlags.context, "context", "", "Kubernetes context to use") + rootCmd.PersistentFlags().StringVarP(&globalFlags.namespace, "namespace", "n", "", "Kubernetes namespace") +} diff --git a/cmd/cozyctl/cmd/vm.go b/cmd/cozyctl/cmd/vm.go new file mode 100644 index 00000000..6178f9d9 --- /dev/null +++ b/cmd/cozyctl/cmd/vm.go @@ -0,0 +1,106 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "syscall" +) + +// vmKindPrefix maps application Kind to the release prefix used by KubeVirt VMs. +func vmKindPrefix(kind string) (string, bool) { + switch kind { + case "VirtualMachine": + return "virtual-machine", true + case "VMInstance": + return "vm-instance", true + default: + return "", false + } +} + +// resolveVMArgs takes CLI args (type, name or type/name), resolves the application type +// via discovery, validates it's a VM kind, and returns the full VM name and namespace. +func resolveVMArgs(args []string) (string, string, error) { + var resourceType, resourceName string + + if len(args) == 1 { + // type/name format + parts := strings.SplitN(args[0], "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("expected type/name format, got %q", args[0]) + } + resourceType, resourceName = parts[0], parts[1] + } else { + resourceType = args[0] + resourceName = args[1] + } + + ctx := context.Background() + typedClient, _, err := newClients() + if err != nil { + return "", "", err + } + + registry, err := discoverAppDefs(ctx, typedClient) + if err != nil { + return "", "", err + } + + info := registry.Resolve(resourceType) + if info == nil { + return "", "", fmt.Errorf("unknown application type %q", resourceType) + } + + prefix, ok := vmKindPrefix(info.Kind) + if !ok { + return "", "", fmt.Errorf("resource type %q (Kind=%s) is not a VirtualMachine or VMInstance", resourceType, info.Kind) + } + + ns, err := getNamespace() + if err != nil { + return "", "", err + } + + vmName := prefix + "-" + resourceName + return vmName, ns, nil +} + +// execVirtctl replaces the current process with virtctl. +func execVirtctl(args []string) error { + virtctlPath, err := exec.LookPath("virtctl") + if err != nil { + return fmt.Errorf("virtctl not found in PATH: %w", err) + } + + // Append kubeconfig/context flags if set + if globalFlags.kubeconfig != "" { + args = append(args, "--kubeconfig", globalFlags.kubeconfig) + } + if globalFlags.context != "" { + args = append(args, "--context", globalFlags.context) + } + + if err := syscall.Exec(virtctlPath, args, os.Environ()); err != nil { + return fmt.Errorf("failed to exec virtctl: %w", err) + } + return nil +} diff --git a/cmd/cozyctl/cmd/vnc.go b/cmd/cozyctl/cmd/vnc.go new file mode 100644 index 00000000..e4a87e25 --- /dev/null +++ b/cmd/cozyctl/cmd/vnc.go @@ -0,0 +1,43 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var vncCmd = &cobra.Command{ + Use: "vnc ", + Short: "Open a VNC connection to a VirtualMachine", + Long: `Open a VNC connection to a VirtualMachine using virtctl. Only valid for VirtualMachine or VMInstance kinds.`, + Args: cobra.ExactArgs(2), + RunE: runVNC, +} + +func init() { + rootCmd.AddCommand(vncCmd) +} + +func runVNC(cmd *cobra.Command, args []string) error { + vmName, ns, err := resolveVMArgs(args) + if err != nil { + return err + } + + virtctlArgs := []string{"virtctl", "vnc", vmName, "-n", ns} + return execVirtctl(virtctlArgs) +} diff --git a/cmd/cozyctl/main.go b/cmd/cozyctl/main.go new file mode 100644 index 00000000..8349124b --- /dev/null +++ b/cmd/cozyctl/main.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "os" + + "github.com/cozystack/cozystack/cmd/cozyctl/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +}