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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ anexia dns zone apply example.com --file changeset.json
records to create and records to delete. Both confirm first, and both read stdin with `--file -`,
which needs `--yes` since the prompt would otherwise read the document.

### Kubernetes

```sh
anexia kubernetes cluster create --name demo --location <location-id>
anexia kubernetes node-pool create --name workers --cluster <cluster-id> --cpus 4 --memory 8 --disk 100
anexia kubernetes cluster kubeconfig get <cluster-id> > kubeconfig
anexia kubernetes node-pool list --cluster <cluster-id>
```

Sizes are entered in GiB on the command line and sent as bytes to the Engine. Passing an existing
prefix turns off the Engine's automatic management of that prefix. `kubeconfig get` polls for the
generated document and is bounded by `--timeout`.

### Global flags

| Flag | Default | Description |
Expand Down Expand Up @@ -195,11 +208,11 @@ cannot reach it: either the library says the Engine has no such operation, or it
implemented one. The distinction matters to whoever picks the work up, so the tables say which
when the library says which, but a `-` is never evidence about the Engine on its own.

The `core`, `network` and `dns` groups below are implemented. Within `network`, `vlan` has every
The `core`, `network`, `dns` and `kubernetes` groups below are implemented. Within `network`, `vlan` has every
verb because go-anxcloud models it generically; `prefix` has every verb hand-written against the
older client, and `address` is read only for now because its write verbs are still to be declared.
Everything after those three groups is a roadmap of what the library can reach, read off
go-anxcloud v0.14.5 and not verified against the Engine.
The remaining groups, starting with `vsphere`, are roadmap items read off go-anxcloud v0.14.5 and
not verified against the Engine.

### core

Expand Down Expand Up @@ -242,8 +255,8 @@ go-anxcloud v0.14.5 and not verified against the Engine.

| Resource | list | get | create | update | delete | extra |
| --- | :-: | :-: | :-: | :-: | :-: | --- |
| `kubernetes cluster` | [ ] | [ ] | [ ] | [ ] | [ ] | `kubeconfig get`/`delete` [ ] |
| `kubernetes node-pool` | [ ] | [ ] | [ ] | [ ] | [ ] | |
| `kubernetes cluster` | [x] | [x] | [x] | - | [x] | `kubeconfig get`/`delete` [x]; updates unsupported by the library |
| `kubernetes node-pool` | [x] | [x] | [x] | - | [x] | `--cluster` filter [x]; updates unsupported by the library |
| `kubernetes disk` | [ ] | [ ] | [ ] | [ ] | [ ] | legacy client only |
| `kubernetes network` | [ ] | [ ] | [ ] | [ ] | [ ] | legacy client only |

Expand Down
3 changes: 2 additions & 1 deletion docs/cli-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ Group, noun, verb, in that order, always. The noun is singular so the sentence r
`resource.Noun` rather than `resource.Group` to build a noun and the alias comes with it.

Groups mirror the Anexia Engine's own API areas rather than inventing a taxonomy: `core`,
`network` and `dns`, and later `vsphere`, `kubernetes`, `lbaas`, `e5e`, `frontier`, `storage`.
`network`, `dns` and `kubernetes`; roadmap groups include `vsphere`, `lbaas`, `e5e`, `frontier`,
and `storage`.
The singular rule does not apply to them, because Anexia named them, not us. Two commands sit
outside this scheme because they never talk to the Engine: `anexia config` and `anexia version`.

Expand Down
97 changes: 57 additions & 40 deletions internal/cli/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,41 +96,51 @@ var flagNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`)
// a missing separator that would otherwise be mistaken for a colon in the
// nested cause.
var engineActions = map[string]string{
"anexia core location list": "listing locations",
"anexia core location get": `reading location "placeholder"`,
"anexia core resource list": "listing resources",
"anexia core resource get": `reading resource "placeholder"`,
"anexia core resource tag list": `listing tags of resource "placeholder"`,
"anexia core resource tag add": `tagging resource "placeholder"`,
"anexia core resource tag remove": `untagging resource "placeholder"`,
"anexia core tag list": "listing tags",
"anexia core tag get": `reading tag "placeholder"`,
"anexia core tag create": `creating tag "placeholder"`,
"anexia core tag delete": `deleting tag "placeholder"`,
"anexia core service list": "listing services",
"anexia network vlan list": "listing vlans",
"anexia network vlan get": `reading vlan "placeholder"`,
"anexia network vlan create": `creating vlan "placeholder"`,
"anexia network vlan update": `reading vlan "placeholder"`,
"anexia network vlan delete": `deleting vlan "placeholder"`,
"anexia network prefix list": "listing prefixes",
"anexia network prefix get": `reading prefix "placeholder"`,
"anexia network prefix create": "creating prefix",
"anexia network prefix update": `updating prefix "placeholder"`,
"anexia network prefix delete": `deleting prefix "placeholder"`,
"anexia network address list": "listing addresses",
"anexia network address get": `reading address "placeholder"`,
"anexia dns zone list": "listing zones",
"anexia dns zone get": `reading zone "placeholder"`,
"anexia dns zone create": `creating zone "placeholder"`,
"anexia dns zone update": `reading zone "placeholder"`,
"anexia dns zone delete": `deleting zone "placeholder"`,
"anexia dns zone import": `importing zone "placeholder"`,
"anexia dns zone apply": `applying changeset to zone "placeholder"`,
"anexia dns record list": "listing records",
"anexia dns record create": `creating record "placeholder"`,
"anexia dns record update": `reading record "placeholder"`,
"anexia dns record delete": `deleting record "placeholder"`,
"anexia core location list": "listing locations",
"anexia core location get": `reading location "placeholder"`,
"anexia core resource list": "listing resources",
"anexia core resource get": `reading resource "placeholder"`,
"anexia core resource tag list": `listing tags of resource "placeholder"`,
"anexia core resource tag add": `tagging resource "placeholder"`,
"anexia core resource tag remove": `untagging resource "placeholder"`,
"anexia core tag list": "listing tags",
"anexia core tag get": `reading tag "placeholder"`,
"anexia core tag create": `creating tag "placeholder"`,
"anexia core tag delete": `deleting tag "placeholder"`,
"anexia core service list": "listing services",
"anexia network vlan list": "listing vlans",
"anexia network vlan get": `reading vlan "placeholder"`,
"anexia network vlan create": `creating vlan "placeholder"`,
"anexia network vlan update": `reading vlan "placeholder"`,
"anexia network vlan delete": `deleting vlan "placeholder"`,
"anexia network prefix list": "listing prefixes",
"anexia network prefix get": `reading prefix "placeholder"`,
"anexia network prefix create": "creating prefix",
"anexia network prefix update": `updating prefix "placeholder"`,
"anexia network prefix delete": `deleting prefix "placeholder"`,
"anexia network address list": "listing addresses",
"anexia network address get": `reading address "placeholder"`,
"anexia dns zone list": "listing zones",
"anexia dns zone get": `reading zone "placeholder"`,
"anexia dns zone create": `creating zone "placeholder"`,
"anexia dns zone update": `reading zone "placeholder"`,
"anexia dns zone delete": `deleting zone "placeholder"`,
"anexia dns zone import": `importing zone "placeholder"`,
"anexia dns zone apply": `applying changeset to zone "placeholder"`,
"anexia dns record list": "listing records",
"anexia dns record create": `creating record "placeholder"`,
"anexia dns record update": `reading record "placeholder"`,
"anexia dns record delete": `deleting record "placeholder"`,
"anexia kubernetes cluster list": "listing clusters",
"anexia kubernetes cluster get": `reading cluster "placeholder"`,
"anexia kubernetes cluster create": `creating cluster "placeholder"`,
"anexia kubernetes cluster delete": `deleting cluster "placeholder"`,
"anexia kubernetes cluster kubeconfig get": `reading kubeconfig of cluster "placeholder"`,
"anexia kubernetes cluster kubeconfig delete": `deleting kubeconfig of cluster "placeholder"`,
"anexia kubernetes node-pool list": "listing node-pools",
"anexia kubernetes node-pool get": `reading node-pool "placeholder"`,
"anexia kubernetes node-pool create": `creating node-pool "placeholder"`,
"anexia kubernetes node-pool delete": `deleting node-pool "placeholder"`,
}

// engineCommand reports whether cmd is a leaf that talks to the Engine.
Expand Down Expand Up @@ -244,11 +254,14 @@ func TestConformanceLeafAliasesUseKnownVerbs(t *testing.T) {
t.Parallel()

allowed := map[string]map[string]bool{
"anexia core tag delete": {"destroy": true},
"anexia network vlan delete": {"destroy": true},
"anexia network prefix delete": {"destroy": true},
"anexia dns zone delete": {"destroy": true},
"anexia dns record delete": {"destroy": true},
"anexia core tag delete": {"destroy": true},
"anexia network vlan delete": {"destroy": true},
"anexia network prefix delete": {"destroy": true},
"anexia dns zone delete": {"destroy": true},
"anexia dns record delete": {"destroy": true},
"anexia kubernetes cluster delete": {"destroy": true},
"anexia kubernetes cluster kubeconfig delete": {"destroy": true},
"anexia kubernetes node-pool delete": {"destroy": true},
}
checked := 0

Expand Down Expand Up @@ -416,6 +429,10 @@ var invocationFlags = map[string]string{
"version": "4",
"netmask": "24",
"vlan": "placeholder",
"cluster": "placeholder",
"cpus": "1",
"memory": "2",
"disk": "20",
}

// commandInvocationFlags override invocationFlags for one command where a
Expand Down
189 changes: 189 additions & 0 deletions internal/cli/kubernetes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package cli

import (
"fmt"
"io"
"net/url"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.anx.io/go-anxcloud/pkg/apis/common"
corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1"
kubernetesv1 "go.anx.io/go-anxcloud/pkg/apis/kubernetes/v1"
"go.anx.io/go-anxcloud/pkg/utils/pointer"

"github.com/ProbstenHias/anexia-cli/internal/confirm"
"github.com/ProbstenHias/anexia-cli/internal/errmap"
"github.com/ProbstenHias/anexia-cli/internal/resource"
)

// newKubernetesCommand groups cluster and node-pool operations under one
// command tree so both resources use the standard Engine verbs.
func newKubernetesCommand(opts *globalOptions) *cobra.Command {
return resource.Group("kubernetes", "Kubernetes clusters and node pools",
newKubernetesClusterCommand(opts),
newKubernetesNodePoolCommand(opts),
)
}

// newKubernetesClusterCommand exposes the lifecycle supported by the library.
// Clusters do not support updates in go-anxcloud, so no update verb is added.
func newKubernetesClusterCommand(opts *globalOptions) *cobra.Command {
cmd := resource.Command(opts, resource.Spec[kubernetesv1.Cluster, *kubernetesv1.Cluster]{
Noun: "cluster",
Short: "Work with Kubernetes clusters",
List: true,
Get: true,
Delete: true,
Identify: func(cluster *kubernetesv1.Cluster, identifier string) {
cluster.Identifier = identifier
},
CreatePayload: clusterCreateFlags,
// The library says the resource does not support updates.
Columns: []resource.Column[kubernetesv1.Cluster]{
{Name: "identifier", Value: func(cluster *kubernetesv1.Cluster) string { return cluster.Identifier }},
{Name: "name", Value: func(cluster *kubernetesv1.Cluster) string { return cluster.Name }},
},
})
cmd.AddCommand(newKubernetesKubeconfigCommand(opts))
return cmd
}

// clusterCreateFlags builds the cluster payload and leaves omitted boolean
// settings unset so the Engine can apply its own defaults.
func clusterCreateFlags(flags *pflag.FlagSet) func(*kubernetesv1.Cluster) error {
name := flags.String("name", "", "cluster name")
location := flags.String("location", "", "location identifier where the cluster is created")
version := flags.String("version", "", "Kubernetes version, empty uses the Engine default")
needsServiceVMs := flags.Bool("needs-service-vms", false, "create Service VMs")
natGateways := flags.Bool("enable-nat-gateways", false, "enable NAT gateways")
lbaas := flags.Bool("enable-lbaas", false, "enable LBaaS")
autoscaling := flags.Bool("enable-autoscaling", false, "enable autoscaling")
internalPrefix := flags.String("internal-ipv4-prefix", "", "existing prefix identifier; turns off automatic management of this prefix")
externalPrefix := flags.String("external-ipv4-prefix", "", "existing prefix identifier; turns off automatic management of this prefix")
externalIPv6Prefix := flags.String("external-ipv6-prefix", "", "existing prefix identifier; turns off automatic management of this prefix")
allowlist := flags.String("api-server-allowlist", "", "space-separated CIDRs allowed to access the API server")

return func(cluster *kubernetesv1.Cluster) error {
if *name == "" {
return errmap.Usagef("--name is required")
}
if *location == "" {
return errmap.Usagef("--location is required")
}

cluster.Name = *name
cluster.Location = corev1.Location{Identifier: *location}
cluster.Version = *version
cluster.ApiServerAllowlist = *allowlist
if flags.Changed("needs-service-vms") {
cluster.NeedsServiceVMs = pointer.Bool(*needsServiceVMs)
}
if flags.Changed("enable-nat-gateways") {
cluster.EnableNATGateways = pointer.Bool(*natGateways)
}
if flags.Changed("enable-lbaas") {
cluster.EnableLBaaS = pointer.Bool(*lbaas)
}
if flags.Changed("enable-autoscaling") {
cluster.EnableAutoscaling = pointer.Bool(*autoscaling)
}
if *internalPrefix != "" {
cluster.InternalIPv4Prefix = &common.PartialResource{Identifier: *internalPrefix}
cluster.ManageInternalIPv4Prefix = pointer.Bool(false)
}
if *externalPrefix != "" {
cluster.ExternalIPv4Prefix = &common.PartialResource{Identifier: *externalPrefix}
cluster.ManageExternalIPv4Prefix = pointer.Bool(false)
}
if *externalIPv6Prefix != "" {
cluster.ExternalIPv6Prefix = &common.PartialResource{Identifier: *externalIPv6Prefix}
cluster.ManageExternalIPv6Prefix = pointer.Bool(false)
}
return nil
}
}

// newKubernetesKubeconfigCommand adds the document operations under a cluster.
func newKubernetesKubeconfigCommand(opts *globalOptions) *cobra.Command {
return resource.Noun("kubeconfig", "kubeconfigs", "Manage the kubeconfig of a cluster",
newKubernetesKubeconfigGetCommand(opts),
newKubernetesKubeconfigDeleteCommand(opts),
)
}

// newKubernetesKubeconfigGetCommand prints the document verbatim after the
// library requests and polls for it when the cluster has none.
func newKubernetesKubeconfigGetCommand(opts *globalOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "get <cluster-id>",
Short: "Get a cluster kubeconfig",
Long: "Get a cluster kubeconfig. This triggers the Engine's request-kubeconfig rule and polls until the kubeconfig appears, bounded by --timeout.",
Args: cobra.ExactArgs(1),
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if err := validateKubeconfigCluster(args[0]); err != nil {
return err
}
apiClient, err := opts.API(cmd.Flags())
if err != nil {
return err
}
ctx, cancel := opts.Context(cmd.Context())
defer cancel()
config, err := kubernetesv1.GetKubeConfig(ctx, apiClient, args[0])
if err != nil {
return opts.Fail(fmt.Errorf("reading kubeconfig of cluster %q: %w", args[0], err))
}
if _, err := io.WriteString(cmd.OutOrStdout(), config); err != nil {
return fmt.Errorf("writing kubeconfig: %w", err)
}
return nil
}
return cmd
}

// validateKubeconfigCluster is stricter than ValidateIdentifier because
// go-anxcloud interpolates the cluster ID into the kubeconfig rule URL.
func validateKubeconfigCluster(id string) error {
if err := resource.ValidateIdentifier("cluster", id); err != nil {
return err
}
if url.PathEscape(id) != id {
return errmap.Usagef("invalid cluster identifier %q", id)
}
return nil
}

// newKubernetesKubeconfigDeleteCommand confirms before firing the remove rule.
func newKubernetesKubeconfigDeleteCommand(opts *globalOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "delete <cluster-id>",
Aliases: []string{"destroy"},
Short: "Delete a cluster kubeconfig",
Args: cobra.ExactArgs(1),
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if err := validateKubeconfigCluster(args[0]); err != nil {
return err
}
question := fmt.Sprintf("delete kubeconfig of cluster %q", args[0])
if err := confirm.Prompt(cmd.InOrStdin(), cmd.ErrOrStderr(), question, opts.AssumeYes()); err != nil {
return err
}
apiClient, err := opts.API(cmd.Flags())
if err != nil {
return err
}
ctx, cancel := opts.Context(cmd.Context())
defer cancel()
if err := kubernetesv1.RemoveKubeConfig(ctx, apiClient, args[0]); err != nil {
return opts.Fail(fmt.Errorf("deleting kubeconfig of cluster %q: %w", args[0], err))
}
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "deleted kubeconfig of cluster %s\n", args[0]); err != nil {
return fmt.Errorf("writing status: %w", err)
}
return nil
}
return cmd
}
Loading