diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index c3114bc..440fa49 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -9,6 +9,7 @@ on: - internal/api/pb/** # The TF provider docs may change in other ways (e.g. schema), but this should be ok in most cases - internal/terraform/examples/** + - internal/cli/**/commands.go pull_request: branches: - main @@ -16,7 +17,7 @@ on: - docs/** - internal/api/pb/** - internal/terraform/examples/** - + - internal/cli/**/commands.go jobs: build: @@ -56,6 +57,8 @@ jobs: run: task tf-docs-generate - name: Generate protobuf docs run: task pb-docs-generate + - name: Generate CLI docs + run: task cli-docs-generate - name: Install dependencies run: npm ci diff --git a/Taskfile-cli-docs.yml b/Taskfile-cli-docs.yml new file mode 100644 index 0000000..89f12f2 --- /dev/null +++ b/Taskfile-cli-docs.yml @@ -0,0 +1,149 @@ +# https://taskfile.dev + +version: '3' + +tasks: + cli-docs-generate: + desc: Generate CLI documentation in docs/ + env: + OUT_DIR: ./docs/docs/cli/reference + cmds: + - | + set -euo pipefail + + mkdir -p "$OUT_DIR" + + function generate_docs() { + local path=("$@") + local filename="${path[*]}" + local cmd_name="${path[*]}" + local help_output + + help_output=$(go run ./cmd/bx2cloud "${path[@]}" -h 2>&1) + + local subcmds + subcmds=$(echo "$help_output" | awk ' + /subcommands:$/ { in_section=1; next } + /^[^[:space:]]+.*:$/ { in_section=0 } + in_section { gsub(/^[[:space:]]+/, ""); print } + ') + + if [[ ${#path[@]} -eq 0 ]]; then + echo "Extracting top-level help: bx2cloud" + echo "$help_output" > "$OUT_DIR/global.md" + elif [[ -z "$subcmds" ]]; then + local file_path="${OUT_DIR}/$(echo "${filename}" | tr ' ' '_').md" + echo "Extracting help for command: bx2cloud ${cmd_name}" + echo "$help_output" > "$file_path" + fi + + while read -r sub; do + [[ -z "$sub" ]] && continue + generate_docs "${path[@]}" "$sub" + done <<< "$subcmds" + } + + generate_docs + - | + set -euo pipefail + + generate_flags_table() { + local source_file="$1" + local flags_block + flags_block=$(awk '/^flags:/{found=1; next} found' "$source_file") + if [[ -n "$flags_block" && "$(echo "$flags_block" | xargs | tr '[:upper:]' '[:lower:]')" != "none" ]]; then + echo "## Flags" + echo "" + echo "| Flag | Type | Description | Default |" + echo "|------|------|-------------|---------|" + printf "%s\n" "$flags_block" | awk ' + function output_flag() { + if (name == "") return; + default_val = ""; + if (match(description, /\(default "([^"]*)"\)/, m)) { + default_val = m[1]; + gsub(/\(default "[^"]*"\)/, "", description); + } else if (match(description, /\(default ([^)]*)\)/, m)) { + default_val = m[1]; + gsub(/\(default [^)]*\)/, "", description); + } + gsub(/^[ \t]+|[ \t]+$/, "", description); + gsub(/<[^>]+>/, "`&`", description); + type_cell = (type == "") ? "" : "`" type "`"; + printf "| `%s` | %s | %s | %s |\n", name, type_cell, description, default_val; + } + /^[[:space:]]*-/ { + output_flag(); + name = $1; + type = (NF > 1) ? $2 : ""; + description = ""; + next; + } + /^[[:space:]]{2,}/ { + line_content = $0; + gsub(/^[ \t]+/, "", line_content); + description = (description == "") ? line_content : description " " line_content; + } + END { output_flag(); } + ' + fi + } + + for file in "$OUT_DIR"/*.md; do + echo "Processing $file..." + { + if [[ "$(basename "$file")" == "global.md" ]]; then + echo "---" + echo "title: \"Global flags\"" + echo "sidebar_position: 1" + echo "custom_edit_url: null" + echo "---" + echo "# Global flags" + echo "" + echo "These flags can be used with any command." + echo "" + echo "## Usage" + echo "" + echo "\`\`\`" + echo "bx2cloud -t 127.0.0.1:9876 version" + echo "\`\`\`" + echo "" + else + content=$(<"$file") + cmd_line=$(echo "$content" | grep -m 1 '^bx2cloud .*:') || { + echo "❌ Error: Could not find 'bx2cloud command:' line in '$file'. Aborting." >&2 + exit 1 + } + cmd=$(echo "$cmd_line" | cut -d':' -f1) + desc=$(echo "$cmd_line" | cut -d':' -f2- | sed 's/^ *//') + usage_line=$(echo "$content" | grep '^usage:' || echo "") + usage_command="" + if [[ -n "$usage_line" ]]; then + usage_command=$(echo "$usage_line" | cut -d':' -f2- | sed 's/^ *//') + fi + echo "---" + echo "title: \"${cmd#bx2cloud }\"" + echo "custom_edit_url: null" + echo "---" + echo "# $cmd" + echo "" + echo "$desc" + echo "" + if [[ -n "$usage_command" ]]; then + echo "## Usage" + echo "" + echo "\`\`\`" + echo "$usage_command" + echo "\`\`\`" + echo "" + fi + fi + generate_flags_table "$file" + } > "$file.tmp" + if [[ -s "$file.tmp" ]]; then + mv "$file.tmp" "$file" + else + rm -f "$file.tmp" + fi + done + - "echo '{\"position\": 99, \"label\": \"Reference\", \"link\": { \"type\": \"generated-index\", \"title\": \"CLI reference\", \"slug\": \"cli/reference\" }}' > $OUT_DIR/_category_.json" \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 0efb3bf..2d8f78d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,7 +2,18 @@ version: "3" +includes: + cli-docs: + taskfile: ./Taskfile-cli-docs.yml + flatten: true + tasks: + docs-generate: + desc: Generate documentation in docs/ + deps: + - pb-docs-generate + - tf-docs-generate + - cli-docs-generate pb-generate: desc: Generate Go protobuf and gRPC code dir: internal/api/pb @@ -55,23 +66,30 @@ tasks: tf-docs-generate: desc: Generate Terraform provider documentation in docs/ cmds: - - go run -modfile tools/go.mod github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-dir ./cmd/terraform-provider-bx2cloud --provider-name bx2cloud --examples-dir ../../internal/terraform/examples --rendered-website-dir ../../docs/docs/terraform/autogenerated + - go run -modfile tools/go.mod + github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate + --provider-dir ./cmd/terraform-provider-bx2cloud + --provider-name bx2cloud + --examples-dir ../../internal/terraform/examples + --rendered-website-dir ../../docs/docs/terraform/reference - task: tf-docs-docusaurify tf-docs-docusaurify: desc: Adjust documentation generated by tfplugindocs to docusaurus supported attributed + env: + OUT_DIR: ./docs/docs/terraform/reference vars: DATA_SOURCES: - sh: find docs/docs/terraform/autogenerated/data-sources -name '*.md' + sh: find ./docs/docs/terraform/reference/data-sources -name '*.md' RESOURCES: - sh: find docs/docs/terraform/autogenerated/resources -name '*.md' + sh: find ./docs/docs/terraform/reference/resources -name '*.md' cmds: - - "echo '{\"position\": 99, \"label\": \"Reference\", \"link\": { \"type\": \"generated-index\", \"title\": \"Terraform provider reference\" }}' > ./docs/docs/terraform/autogenerated/_category_.json" - - "mv ./docs/docs/terraform/autogenerated/index.md ./docs/docs/terraform/autogenerated/provider.md" - - "sed -i '/^page_title:/c\\title: Provider\\nsidebar_position: 1\\ncustom_edit_url: null' ./docs/docs/terraform/autogenerated/provider.md" - - "echo '{\"position\": 2, \"label\": \"Data sources\"}' > ./docs/docs/terraform/autogenerated/data-sources/_category_.json" - - "echo '{\"position\": 3, \"label\": \"Resources\"}' > ./docs/docs/terraform/autogenerated/resources/_category_.json" + - "echo '{\"position\": 99, \"label\": \"Reference\", \"link\": { \"type\": \"generated-index\", \"title\": \"Terraform provider reference\", \"slug\": \"terraform/reference\" }}' > $OUT_DIR/_category_.json" + - "mv $OUT_DIR/index.md $OUT_DIR/provider.md" + - "sed -i '/^page_title:/c\\title: Provider\\nsidebar_position: 1\\ncustom_edit_url: null' $OUT_DIR/provider.md" + - "echo '{\"position\": 2, \"label\": \"Data sources\"}' > $OUT_DIR/data-sources/_category_.json" + - "echo '{\"position\": 3, \"label\": \"Resources\"}' > $OUT_DIR/resources/_category_.json" - for: { var: DATA_SOURCES } cmd: "sed -i -E 's/^page_title: \"([^ ]+).*$/title: \"\\1\"\\ncustom_edit_url: null/' {{.ITEM}}" - for: { var: RESOURCES } - cmd: "sed -i -E 's/^page_title: \"([^ ]+).*$/title: \"\\1\"\\ncustom_edit_url: null/' {{.ITEM}}" + cmd: "sed -i -E 's/^page_title: \"([^ ]+).*$/title: \"\\1\"\\ncustom_edit_url: null/' {{.ITEM}}" \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore index 1a39b03..7dc7fc2 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -20,7 +20,10 @@ yarn-debug.log* yarn-error.log* # Terraform provider docs should be autogenerated upon deployment to ensure that they are up to date -docs/terraform/autogenerated/ +docs/terraform/reference/ # gRPC docs should be autogenerated upon deployment to ensure that they are up to date -docs/api/grpc-reference.* \ No newline at end of file +docs/api/grpc-reference.* + +# CLI docs should be autogenerated upon deployment to ensure that they are up to date +docs/cli/reference/ \ No newline at end of file diff --git a/docs/docs/cli/installation.md b/docs/docs/cli/installation.md index f401beb..1220790 100644 --- a/docs/docs/cli/installation.md +++ b/docs/docs/cli/installation.md @@ -18,10 +18,10 @@ It's possible to download a pre-built binary of the CLI from [GitHub releases](h A container image is available on [Docker Hub](https://hub.docker.com/r/benasbudrys/bx2cloud-cli). -As an example, running `bx2cloud container list` can be achieved with: +As an example, try running `bx2cloud version`: ```sh -docker run --rm benasbudrys/bx2cloud-cli -t : container list +docker run --rm benasbudrys/bx2cloud-cli -t : version ``` ### 3. Building from source diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2976168..32b52cf 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,12 +1,11 @@ package cli import ( + "flag" "fmt" - "io" "os" - "strconv" - "github.com/BenasB/bx2cloud/internal/api/pb" + "github.com/BenasB/bx2cloud/internal/cli/common" "github.com/BenasB/bx2cloud/internal/cli/container" "github.com/BenasB/bx2cloud/internal/cli/exits" "github.com/BenasB/bx2cloud/internal/cli/introspection" @@ -16,314 +15,40 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -// TODO: use flags package +var globalFlagSet = flag.NewFlagSet("bx2cloud", flag.ExitOnError) +var globalFlags = struct { + target *string +}{ + target: globalFlagSet.String("t", "localhost:8080", "API target :"), +} func Run(args []string) exits.ExitCode { - if len(args) < 1 { - fmt.Fprintf(os.Stderr, "Missing command\n") - return exits.MISSING_COMMAND + subcommands := make([]*common.CliCommand, 0) + subcommands = append(subcommands, introspection.Commands...) + subcommands = append(subcommands, network.Commands...) + subcommands = append(subcommands, subnetwork.Commands...) + subcommands = append(subcommands, container.Commands...) + mainCommand := common.NewCliSubcommand(globalFlagSet.Name(), subcommands) + + globalFlagSet.Usage = func() { + common.FprintSubcommands(os.Stderr, globalFlagSet.Name(), subcommands) + + fmt.Fprintf(os.Stderr, "flags:\n") + globalFlagSet.PrintDefaults() } - - command := args[0] - args = args[1:] - - target := "localhost:8080" - if command == "-t" { - target = args[0] - args = args[1:] - - if len(args) < 1 { - fmt.Fprintf(os.Stderr, "Missing command\n") - return exits.MISSING_COMMAND - } - - command = args[0] - args = args[1:] + if err := globalFlagSet.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return exits.BAD_FLAG } - conn, err := newConn(target) + conn, err := newConn(*globalFlags.target) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) - return exits.SERVER_ERROR + return exits.BAD_FLAG } defer conn.Close() - var cmdErrCode exits.ExitCode - var cmdErr error - switch command { - case "version": - client := pb.NewIntrospectionServiceClient(conn) - introspection.Version(client) - return exits.SUCCESS - case "network": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing subcommand\n") - return exits.MISSING_SUBCOMMAND - } - subcommand := args[0] - args = args[1:] - - client := pb.NewNetworkServiceClient(conn) - cmdErrCode = exits.NETWORK_ERROR - - switch subcommand { - case "list": - cmdErr = network.List(client) - case "get": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = network.Get(client, uint32(id)) - case "delete": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = network.Delete(client, uint32(id)) - case "create": - // TODO: #2 Read a file and fallback to os.Stdin if no file is supplied - yamlBytes, err := io.ReadAll(os.Stdin) - if err != nil { - cmdErr = err - break - } - - cmdErr = network.Create(client, yamlBytes) - case "update": - yamlBytes, err := io.ReadAll(os.Stdin) - if err != nil { - cmdErr = err - break - } - - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - - cmdErr = network.Update(client, uint32(id), yamlBytes) - default: - fmt.Fprintf(os.Stderr, "Unrecognized subcommand '%s'\n", subcommand) - return exits.UNKNOWN_SUBCOMMAND - } - case "subnetwork": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing subcommand\n") - return exits.MISSING_SUBCOMMAND - } - subcommand := args[0] - args = args[1:] - - client := pb.NewSubnetworkServiceClient(conn) - cmdErrCode = exits.SUBNETWORK_ERROR - - switch subcommand { - case "list": - cmdErr = subnetwork.List(client) - case "get": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = subnetwork.Get(client, uint32(id)) - case "delete": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = subnetwork.Delete(client, uint32(id)) - case "create": - yamlBytes, err := io.ReadAll(os.Stdin) - if err != nil { - cmdErr = err - break - } - - cmdErr = subnetwork.Create(client, yamlBytes) - case "update": - yamlBytes, err := io.ReadAll(os.Stdin) - if err != nil { - cmdErr = err - break - } - - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - - cmdErr = subnetwork.Update(client, uint32(id), yamlBytes) - default: - fmt.Fprintf(os.Stderr, "Unrecognized subcommand '%s'\n", subcommand) - return exits.UNKNOWN_SUBCOMMAND - } - case "container": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing subcommand\n") - return exits.MISSING_SUBCOMMAND - } - subcommand := args[0] - args = args[1:] - - client := pb.NewContainerServiceClient(conn) - cmdErrCode = exits.CONTAINER_ERROR - - switch subcommand { - case "list": - cmdErr = container.List(client) - case "get": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = container.Get(client, uint32(id)) - case "delete": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = container.Delete(client, uint32(id)) - case "create": - yamlBytes, err := io.ReadAll(os.Stdin) - if err != nil { - cmdErr = err - break - } - - cmdErr = container.Create(client, yamlBytes) - case "exec": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = container.Exec(client, uint32(id), args) - case "start": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = container.Start(client, uint32(id)) - case "stop": - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Missing id argument\n") - return exits.MISSING_ARGUMENT - } - - idString := args[0] - args = args[1:] - - id, err := strconv.ParseUint(idString, 10, 32) - if err != nil { - fmt.Fprintf(os.Stderr, "Could not convert the id '%d' argument to an integer\n", id) - return exits.BAD_ARGUMENT - } - cmdErr = container.Stop(client, uint32(id)) - default: - fmt.Fprintf(os.Stderr, "Unrecognized subcommand '%s'\n", subcommand) - return exits.UNKNOWN_SUBCOMMAND - } - default: - fmt.Fprintf(os.Stderr, "Unrecognized command '%s'\n", command) - return exits.UNKNOWN_COMMAND - } - - if cmdErr != nil { - fmt.Fprintf(os.Stderr, "%v\n", cmdErr) - return cmdErrCode - } - - return exits.SUCCESS + return mainCommand.Execute(globalFlagSet.Args(), conn, []string{}) } func newConn(target string) (*grpc.ClientConn, error) { diff --git a/internal/cli/common/arg_parse.go b/internal/cli/common/arg_parse.go new file mode 100644 index 0000000..99a2e42 --- /dev/null +++ b/internal/cli/common/arg_parse.go @@ -0,0 +1,25 @@ +package common + +import ( + "fmt" + "strconv" + + "github.com/BenasB/bx2cloud/internal/cli/exits" +) + +func ParseUint32Arg(args *[]string) (uint32, exits.ExitCode, error) { + if len(*args) == 0 { + return 0, exits.MISSING_ARGUMENT, fmt.Errorf("missing argument") + } + + argString := (*args)[0] + + arg, err := strconv.ParseUint(argString, 10, 32) + if err != nil { + return 0, exits.BAD_ARGUMENT, fmt.Errorf("Could not convert '%d' to an unsigned integer\n", arg) + } + + *args = (*args)[1:] + + return uint32(arg), exits.SUCCESS, nil +} diff --git a/internal/cli/common/arg_parse_test.go b/internal/cli/common/arg_parse_test.go new file mode 100644 index 0000000..0d901d6 --- /dev/null +++ b/internal/cli/common/arg_parse_test.go @@ -0,0 +1,64 @@ +package common_test + +import ( + "strings" + "testing" + + "github.com/BenasB/bx2cloud/internal/cli/common" +) + +func TestArgParse_Uint32(t *testing.T) { + var tests = []struct { + inArgs []string + outArgs []string + out uint32 + }{ + {[]string{}, []string{}, 0}, + {[]string{""}, []string{""}, 0}, + {[]string{"1"}, []string{}, 1}, + {[]string{"4321", "foo"}, []string{"foo"}, 4321}, + {[]string{"foo", "bar"}, []string{"foo", "bar"}, 0}, + } + + for _, tt := range tests { + t.Run(strings.Join(tt.inArgs, ","), func(t *testing.T) { + out, _, _ := common.ParseUint32Arg(&tt.inArgs) + if !arrEqual(tt.inArgs, tt.outArgs) || out != tt.out { + t.Fatalf( + "got %q, %d, want %q, %d", + tt.inArgs, + out, + tt.outArgs, + tt.out, + ) + } + }) + } +} + +func TestArgParse_Uint32_Chained(t *testing.T) { + args := []string{"1", "2", "foo"} + outArgs := []string{"foo"} + out1, _, _ := common.ParseUint32Arg(&args) + out2, _, _ := common.ParseUint32Arg(&args) + if !arrEqual(args, outArgs) || out1 != 1 || out2 != 2 { + t.Fatalf( + "got %q, %d, %d, want %q, %d, %d", + args, + out1, + out2, + outArgs, + 1, + 2, + ) + } +} + +func arrEqual[T comparable](a []T, b []T) bool { + for i, v := range a { + if v != b[i] { + return false + } + } + return true +} diff --git a/internal/cli/common/command.go b/internal/cli/common/command.go new file mode 100644 index 0000000..aeb7765 --- /dev/null +++ b/internal/cli/common/command.go @@ -0,0 +1,133 @@ +package common + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/BenasB/bx2cloud/internal/cli/exits" + "google.golang.org/grpc" +) + +type CliCommand struct { + description string + argDescription string + flagSet *flag.FlagSet + handler func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) + subcommands []*CliCommand +} + +func NewCliCommand( + name string, + description string, + argDescription string, + handler func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error), +) *CliCommand { + return NewCliCommandWithFlags(name, description, argDescription, handler, func(fs *flag.FlagSet) {}) +} + +func NewCliCommandWithFlags( + name string, + description string, + argDescription string, + handler func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error), + flagSetUp func(*flag.FlagSet), +) *CliCommand { + flagSet := flag.NewFlagSet(name, flag.ContinueOnError) + + flagSetUp(flagSet) + + return &CliCommand{ + description: description, + argDescription: argDescription, + handler: handler, + flagSet: flagSet, + } +} + +func NewCliSubcommand(name string, subcommands []*CliCommand) *CliCommand { + flagSet := flag.NewFlagSet(name, flag.ContinueOnError) + + return &CliCommand{ + subcommands: subcommands, + flagSet: flagSet, + } +} + +func (c *CliCommand) Execute(args []string, conn *grpc.ClientConn, cmdNameChain []string) exits.ExitCode { + cmdNameChain = append(cmdNameChain, c.flagSet.Name()) + + c.flagSet.Usage = func() { + fullName := strings.Join(cmdNameChain, " ") + if len(c.subcommands) == 0 { + fmt.Fprintf(c.flagSet.Output(), "%s: %s\n", fullName, c.description) + fmt.Fprintf(c.flagSet.Output(), "usage: %s %s\n", fullName, c.argDescription) + + fmt.Fprintf(c.flagSet.Output(), "flags:") + hasFlags := false + c.flagSet.VisitAll(func(f *flag.Flag) { hasFlags = true }) + if hasFlags { + fmt.Fprintf(c.flagSet.Output(), "\n") + c.flagSet.PrintDefaults() + } else { + fmt.Fprintf(c.flagSet.Output(), " none\n") + } + } else { + FprintSubcommands(c.flagSet.Output(), fullName, c.subcommands) + } + } + + if err := c.flagSet.Parse(args); err != nil { + if err == flag.ErrHelp { + return exits.SUCCESS + } + + return exits.BAD_FLAG + } + + args = c.flagSet.Args() + + if len(c.subcommands) == 0 { + // TODO: Pass flag data onto handler + + if c.handler == nil { + fmt.Fprintf(os.Stderr, "This command does not have a handler attached to it, please report to the developer\n") + return exits.MISSING_SUBCOMMAND + } + + exitCode, err := c.handler(args, conn) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + } + + return exitCode + } + + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Missing subcommand\n") + return exits.MISSING_SUBCOMMAND + } + + subcommand := args[0] + args = args[1:] + + for _, sc := range c.subcommands { + if subcommand != sc.flagSet.Name() { + continue + } + + return sc.Execute(args, conn, cmdNameChain) + } + + fmt.Fprintf(os.Stderr, "Unrecognized subcommand '%s'\n", subcommand) + return exits.UNKNOWN_SUBCOMMAND +} + +func FprintSubcommands(w io.Writer, cmdName string, subcommands []*CliCommand) { + fmt.Fprintf(w, "%s subcommands:\n", cmdName) + for _, sc := range subcommands { + fmt.Fprintf(w, " %s\n", sc.flagSet.Name()) + } +} diff --git a/internal/cli/container/commands.go b/internal/cli/container/commands.go index c51a5de..2a91a2c 100644 --- a/internal/cli/container/commands.go +++ b/internal/cli/container/commands.go @@ -1,230 +1,131 @@ package container import ( - "context" "fmt" "io" "os" - "text/tabwriter" - "time" "github.com/BenasB/bx2cloud/internal/api/pb" - "golang.org/x/term" - "google.golang.org/protobuf/types/known/emptypb" - "gopkg.in/yaml.v3" + "github.com/BenasB/bx2cloud/internal/cli/common" + "github.com/BenasB/bx2cloud/internal/cli/exits" + "google.golang.org/grpc" ) -func newWriter() *tabwriter.Writer { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "id\timage\tstatus\tip\n") - return w -} - -func print(w *tabwriter.Writer, container *pb.Container) { - cidr := fmt.Sprintf("%d.%d.%d.%d/%d", - byte(container.Address>>24), - byte(container.Address>>16), - byte(container.Address>>8), - byte(container.Address), - container.PrefixLength) - - status := container.Status - if container.Status == "running" { - since := time.Since(container.StartedAt.AsTime()) - status = fmt.Sprintf("%s (%s)", container.Status, since.Round(time.Second)) - } - - fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", container.Id, container.Image, status, cidr) -} - -func List(client pb.ContainerServiceClient) error { - stream, err := client.List(context.Background(), &emptypb.Empty{}) - if err != nil { - return err - } - - w := newWriter() - defer w.Flush() - for { - container, err := stream.Recv() - if err == io.EOF { - break - } - if err != nil { - return err - } - print(w, container) - } - - return nil -} - -func Get(client pb.ContainerServiceClient, id uint32) error { - container, err := client.Get(context.Background(), &pb.ContainerIdentificationRequest{ - Id: id, - }) - if err != nil { - return err - } - - w := newWriter() - defer w.Flush() - print(w, container) - - return nil -} - -func Delete(client pb.ContainerServiceClient, id uint32) error { - _, err := client.Delete(context.Background(), &pb.ContainerIdentificationRequest{ - Id: id, - }) - if err != nil { - return err - } - - fmt.Printf("Successfully deleted %d\n", id) - - return nil -} - -func Create(client pb.ContainerServiceClient, yamlBytes []byte) error { - input := &containerCreation{} - if err := yaml.Unmarshal(yamlBytes, &input); err != nil { - return err - } - - if err := input.Validate(); err != nil { - return err - } - - req := &pb.ContainerCreationRequest{ - SubnetworkId: input.SubnetworkId, - Image: input.Image, - Entrypoint: input.Entrypoint, - Cmd: input.Cmd, - Env: input.Env, - } - - resp, err := client.Create(context.Background(), req) - if err != nil { - return err - } - - fmt.Printf("Successfully created %d\n", resp.Id) - - return nil -} - -func Exec(client pb.ContainerServiceClient, id uint32, args []string) error { - termFd := int(os.Stdin.Fd()) - - if !term.IsTerminal(termFd) { - return fmt.Errorf("standard input must be a terminal for container exec") - } - - oldState, err := term.MakeRaw(termFd) - if err != nil { - return fmt.Errorf("failed to put the terminal into a raw mode: %w", err) - } - defer term.Restore(termFd, oldState) - - width, height, err := term.GetSize(termFd) - if err != nil { - return fmt.Errorf("failed to retrieve the size of the terminal: %w", err) - } - - stream, err := client.Exec(context.Background()) - if err != nil { - return err - } - - var terminal *string - if envTerm, ok := os.LookupEnv("TERM"); ok { - terminal = &envTerm - } - - stream.Send(&pb.ContainerExecRequest{ - Input: &pb.ContainerExecRequest_Initialization{ - Initialization: &pb.ContainerExecInitializationRequest{ - Identification: &pb.ContainerIdentificationRequest{ - Id: id, +var Commands = []*common.CliCommand{ + common.NewCliSubcommand( + "container", + []*common.CliCommand{ + common.NewCliCommand( + "list", + "Retrieves all existing containers", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewContainerServiceClient(conn) + if err := List(client); err != nil { + return exits.CONTAINER_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "get", + "Retrieves a specified container", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewContainerServiceClient(conn) + if err := List(client); err != nil { + return exits.CONTAINER_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "delete", + "Deletes a specified container. Before that, stops it if it is running.", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewContainerServiceClient(conn) + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Delete(client, id); err != nil { + return exits.CONTAINER_ERROR, err + } + return exits.SUCCESS, nil }, - ConsoleWidth: int32(width), - ConsoleHeight: int32(height), - Terminal: terminal, - Args: args, - }, + ), + common.NewCliCommand( + "create", + "Creates and starts a new container resource", + "< file.yaml", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewContainerServiceClient(conn) + + // TODO: #2 Read a file and fallback to os.Stdin if no file is supplied + yamlBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return exits.CONTAINER_ERROR, err + } + + if err := Create(client, yamlBytes); err != nil { + return exits.CONTAINER_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "exec", + "Starts a shell process inside a specified container or executes a specific command, if specified", + " [cmd]", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewContainerServiceClient(conn) + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Exec(client, id, args); err != nil { + return exits.CONTAINER_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "start", + "Starts a specified container resource", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewContainerServiceClient(conn) + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Start(client, id); err != nil { + return exits.CONTAINER_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "stop", + "Stops a specified container resource", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewContainerServiceClient(conn) + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Stop(client, id); err != nil { + return exits.CONTAINER_ERROR, err + } + return exits.SUCCESS, nil + }, + ), }, - }) - - go func() { - buf := make([]byte, 8192) - for { - n, err := os.Stdin.Read(buf) - if err != nil { - return - } - if n > 0 { - err = stream.Send(&pb.ContainerExecRequest{ - Input: &pb.ContainerExecRequest_Stdin{Stdin: buf[:n]}, - }) - if err != nil { - return - } - } - } - }() - - var exitCode int - for { - resp, err := stream.Recv() - if err == io.EOF { - break - } - if err != nil { - return err - } - switch p := resp.Output.(type) { - case *pb.ContainerExecResponse_Stdout: - os.Stdout.Write(p.Stdout) - case *pb.ContainerExecResponse_ExitCode: - exitCode = int(p.ExitCode) - } - } - - if err := term.Restore(termFd, oldState); err != nil { - return err - } - - fmt.Printf("Exited with code %d\n", exitCode) - - return nil -} - -func Start(client pb.ContainerServiceClient, id uint32) error { - resp, err := client.Start(context.Background(), &pb.ContainerIdentificationRequest{ - Id: id, - }) - - if err != nil { - return err - } - - fmt.Printf("Container %d is now %q\n", resp.Id, resp.Status) - - return nil -} - -func Stop(client pb.ContainerServiceClient, id uint32) error { - resp, err := client.Stop(context.Background(), &pb.ContainerIdentificationRequest{ - Id: id, - }) - - if err != nil { - return err - } - - fmt.Printf("Container %d is now %q\n", resp.Id, resp.Status) - - return nil + ), } diff --git a/internal/cli/container/handlers.go b/internal/cli/container/handlers.go new file mode 100644 index 0000000..c51a5de --- /dev/null +++ b/internal/cli/container/handlers.go @@ -0,0 +1,230 @@ +package container + +import ( + "context" + "fmt" + "io" + "os" + "text/tabwriter" + "time" + + "github.com/BenasB/bx2cloud/internal/api/pb" + "golang.org/x/term" + "google.golang.org/protobuf/types/known/emptypb" + "gopkg.in/yaml.v3" +) + +func newWriter() *tabwriter.Writer { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "id\timage\tstatus\tip\n") + return w +} + +func print(w *tabwriter.Writer, container *pb.Container) { + cidr := fmt.Sprintf("%d.%d.%d.%d/%d", + byte(container.Address>>24), + byte(container.Address>>16), + byte(container.Address>>8), + byte(container.Address), + container.PrefixLength) + + status := container.Status + if container.Status == "running" { + since := time.Since(container.StartedAt.AsTime()) + status = fmt.Sprintf("%s (%s)", container.Status, since.Round(time.Second)) + } + + fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", container.Id, container.Image, status, cidr) +} + +func List(client pb.ContainerServiceClient) error { + stream, err := client.List(context.Background(), &emptypb.Empty{}) + if err != nil { + return err + } + + w := newWriter() + defer w.Flush() + for { + container, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return err + } + print(w, container) + } + + return nil +} + +func Get(client pb.ContainerServiceClient, id uint32) error { + container, err := client.Get(context.Background(), &pb.ContainerIdentificationRequest{ + Id: id, + }) + if err != nil { + return err + } + + w := newWriter() + defer w.Flush() + print(w, container) + + return nil +} + +func Delete(client pb.ContainerServiceClient, id uint32) error { + _, err := client.Delete(context.Background(), &pb.ContainerIdentificationRequest{ + Id: id, + }) + if err != nil { + return err + } + + fmt.Printf("Successfully deleted %d\n", id) + + return nil +} + +func Create(client pb.ContainerServiceClient, yamlBytes []byte) error { + input := &containerCreation{} + if err := yaml.Unmarshal(yamlBytes, &input); err != nil { + return err + } + + if err := input.Validate(); err != nil { + return err + } + + req := &pb.ContainerCreationRequest{ + SubnetworkId: input.SubnetworkId, + Image: input.Image, + Entrypoint: input.Entrypoint, + Cmd: input.Cmd, + Env: input.Env, + } + + resp, err := client.Create(context.Background(), req) + if err != nil { + return err + } + + fmt.Printf("Successfully created %d\n", resp.Id) + + return nil +} + +func Exec(client pb.ContainerServiceClient, id uint32, args []string) error { + termFd := int(os.Stdin.Fd()) + + if !term.IsTerminal(termFd) { + return fmt.Errorf("standard input must be a terminal for container exec") + } + + oldState, err := term.MakeRaw(termFd) + if err != nil { + return fmt.Errorf("failed to put the terminal into a raw mode: %w", err) + } + defer term.Restore(termFd, oldState) + + width, height, err := term.GetSize(termFd) + if err != nil { + return fmt.Errorf("failed to retrieve the size of the terminal: %w", err) + } + + stream, err := client.Exec(context.Background()) + if err != nil { + return err + } + + var terminal *string + if envTerm, ok := os.LookupEnv("TERM"); ok { + terminal = &envTerm + } + + stream.Send(&pb.ContainerExecRequest{ + Input: &pb.ContainerExecRequest_Initialization{ + Initialization: &pb.ContainerExecInitializationRequest{ + Identification: &pb.ContainerIdentificationRequest{ + Id: id, + }, + ConsoleWidth: int32(width), + ConsoleHeight: int32(height), + Terminal: terminal, + Args: args, + }, + }, + }) + + go func() { + buf := make([]byte, 8192) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + return + } + if n > 0 { + err = stream.Send(&pb.ContainerExecRequest{ + Input: &pb.ContainerExecRequest_Stdin{Stdin: buf[:n]}, + }) + if err != nil { + return + } + } + } + }() + + var exitCode int + for { + resp, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return err + } + switch p := resp.Output.(type) { + case *pb.ContainerExecResponse_Stdout: + os.Stdout.Write(p.Stdout) + case *pb.ContainerExecResponse_ExitCode: + exitCode = int(p.ExitCode) + } + } + + if err := term.Restore(termFd, oldState); err != nil { + return err + } + + fmt.Printf("Exited with code %d\n", exitCode) + + return nil +} + +func Start(client pb.ContainerServiceClient, id uint32) error { + resp, err := client.Start(context.Background(), &pb.ContainerIdentificationRequest{ + Id: id, + }) + + if err != nil { + return err + } + + fmt.Printf("Container %d is now %q\n", resp.Id, resp.Status) + + return nil +} + +func Stop(client pb.ContainerServiceClient, id uint32) error { + resp, err := client.Stop(context.Background(), &pb.ContainerIdentificationRequest{ + Id: id, + }) + + if err != nil { + return err + } + + fmt.Printf("Container %d is now %q\n", resp.Id, resp.Status) + + return nil +} diff --git a/internal/cli/exits/exits.go b/internal/cli/exits/exits.go index 9fd4b1e..00cf719 100644 --- a/internal/cli/exits/exits.go +++ b/internal/cli/exits/exits.go @@ -4,14 +4,13 @@ type ExitCode int const ( SUCCESS ExitCode = iota - MISSING_COMMAND MISSING_SUBCOMMAND MISSING_ARGUMENT SERVER_ERROR - UNKNOWN_COMMAND UNKNOWN_SUBCOMMAND BAD_ARGUMENT NETWORK_ERROR SUBNETWORK_ERROR CONTAINER_ERROR + BAD_FLAG ) diff --git a/internal/cli/introspection/commands.go b/internal/cli/introspection/commands.go index 3d36651..e9097a1 100644 --- a/internal/cli/introspection/commands.go +++ b/internal/cli/introspection/commands.go @@ -1,23 +1,21 @@ package introspection import ( - "context" - "fmt" - "github.com/BenasB/bx2cloud/internal/api/pb" - "google.golang.org/protobuf/types/known/emptypb" + "github.com/BenasB/bx2cloud/internal/cli/common" + "github.com/BenasB/bx2cloud/internal/cli/exits" + "google.golang.org/grpc" ) -var version = "dev" - -func Version(client pb.IntrospectionServiceClient) { - fmt.Printf("CLI version: %s\n", version) - - resp, err := client.Get(context.Background(), &emptypb.Empty{}) - if err != nil { - fmt.Printf("API version: failed to determine\n") - return - } - - fmt.Printf("API version: %s\n", resp.Version) +var Commands = []*common.CliCommand{ + common.NewCliCommand( + "version", + "Prints out CLI and API version information", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewIntrospectionServiceClient(conn) + Version(client) + return exits.SUCCESS, nil + }, + ), } diff --git a/internal/cli/introspection/handlers.go b/internal/cli/introspection/handlers.go new file mode 100644 index 0000000..3d36651 --- /dev/null +++ b/internal/cli/introspection/handlers.go @@ -0,0 +1,23 @@ +package introspection + +import ( + "context" + "fmt" + + "github.com/BenasB/bx2cloud/internal/api/pb" + "google.golang.org/protobuf/types/known/emptypb" +) + +var version = "dev" + +func Version(client pb.IntrospectionServiceClient) { + fmt.Printf("CLI version: %s\n", version) + + resp, err := client.Get(context.Background(), &emptypb.Empty{}) + if err != nil { + fmt.Printf("API version: failed to determine\n") + return + } + + fmt.Printf("API version: %s\n", resp.Version) +} diff --git a/internal/cli/network/commands.go b/internal/cli/network/commands.go index 7b2dabb..2942e78 100644 --- a/internal/cli/network/commands.go +++ b/internal/cli/network/commands.go @@ -1,126 +1,103 @@ package network import ( - "context" "fmt" "io" "os" - "text/tabwriter" "github.com/BenasB/bx2cloud/internal/api/pb" - "google.golang.org/protobuf/types/known/emptypb" - "gopkg.in/yaml.v3" + "github.com/BenasB/bx2cloud/internal/cli/common" + "github.com/BenasB/bx2cloud/internal/cli/exits" + "google.golang.org/grpc" ) -func newWriter() *tabwriter.Writer { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "id\tinternetAccess\n") - return w -} - -func print(w *tabwriter.Writer, network *pb.Network) { - fmt.Fprintf(w, "%d\t%t\n", network.Id, network.InternetAccess) -} - -func List(client pb.NetworkServiceClient) error { - stream, err := client.List(context.Background(), &emptypb.Empty{}) - if err != nil { - return err - } - - w := newWriter() - defer w.Flush() - for { - network, err := stream.Recv() - if err == io.EOF { - break - } - if err != nil { - return err - } - print(w, network) - } - - return nil -} - -func Get(client pb.NetworkServiceClient, id uint32) error { - network, err := client.Get(context.Background(), &pb.NetworkIdentificationRequest{ - Id: id, - }) - if err != nil { - return err - } - - w := newWriter() - defer w.Flush() - print(w, network) - - return nil -} - -func Delete(client pb.NetworkServiceClient, id uint32) error { - _, err := client.Delete(context.Background(), &pb.NetworkIdentificationRequest{ - Id: id, - }) - if err != nil { - return err - } - - fmt.Printf("Successfully deleted %d\n", id) - - return nil -} - -func Create(client pb.NetworkServiceClient, yamlBytes []byte) error { - input := &networkCreation{} - if err := yaml.Unmarshal(yamlBytes, &input); err != nil { - return err - } - - if err := input.Validate(); err != nil { - return err - } - - req := &pb.NetworkCreationRequest{ - InternetAccess: input.InternetAccess, - } - - resp, err := client.Create(context.Background(), req) - if err != nil { - return err - } - - fmt.Printf("Successfully created %d\n", resp.Id) - - return nil -} - -func Update(client pb.NetworkServiceClient, id uint32, yamlBytes []byte) error { - input := &networkCreation{} - if err := yaml.Unmarshal(yamlBytes, &input); err != nil { - return err - } - - if err := input.Validate(); err != nil { - return err - } - - req := &pb.NetworkUpdateRequest{ - Identification: &pb.NetworkIdentificationRequest{ - Id: id, - }, - Update: &pb.NetworkCreationRequest{ - InternetAccess: input.InternetAccess, +var Commands = []*common.CliCommand{ + common.NewCliSubcommand( + "network", + []*common.CliCommand{ + common.NewCliCommand( + "list", + "Retrieves all existing networks", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewNetworkServiceClient(conn) + if err := List(client); err != nil { + return exits.NETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "get", + "Retrieves a specified network", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewNetworkServiceClient(conn) + if err := List(client); err != nil { + return exits.NETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "delete", + "Deletes a specified network", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewNetworkServiceClient(conn) + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Delete(client, id); err != nil { + return exits.NETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "create", + "Creates a new network resource", + "< file.yaml", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewNetworkServiceClient(conn) + + // TODO: #2 Read a file and fallback to os.Stdin if no file is supplied + yamlBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return exits.NETWORK_ERROR, err + } + + if err := Create(client, yamlBytes); err != nil { + return exits.NETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "update", + "Updates an existing network resource", + "< file.yaml", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewNetworkServiceClient(conn) + + yamlBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return exits.NETWORK_ERROR, err + } + + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Update(client, id, yamlBytes); err != nil { + return exits.NETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), }, - } - - resp, err := client.Update(context.Background(), req) - if err != nil { - return err - } - - fmt.Printf("Successfully updated %d\n", resp.Id) - - return nil + ), } diff --git a/internal/cli/network/handlers.go b/internal/cli/network/handlers.go new file mode 100644 index 0000000..7b2dabb --- /dev/null +++ b/internal/cli/network/handlers.go @@ -0,0 +1,126 @@ +package network + +import ( + "context" + "fmt" + "io" + "os" + "text/tabwriter" + + "github.com/BenasB/bx2cloud/internal/api/pb" + "google.golang.org/protobuf/types/known/emptypb" + "gopkg.in/yaml.v3" +) + +func newWriter() *tabwriter.Writer { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "id\tinternetAccess\n") + return w +} + +func print(w *tabwriter.Writer, network *pb.Network) { + fmt.Fprintf(w, "%d\t%t\n", network.Id, network.InternetAccess) +} + +func List(client pb.NetworkServiceClient) error { + stream, err := client.List(context.Background(), &emptypb.Empty{}) + if err != nil { + return err + } + + w := newWriter() + defer w.Flush() + for { + network, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return err + } + print(w, network) + } + + return nil +} + +func Get(client pb.NetworkServiceClient, id uint32) error { + network, err := client.Get(context.Background(), &pb.NetworkIdentificationRequest{ + Id: id, + }) + if err != nil { + return err + } + + w := newWriter() + defer w.Flush() + print(w, network) + + return nil +} + +func Delete(client pb.NetworkServiceClient, id uint32) error { + _, err := client.Delete(context.Background(), &pb.NetworkIdentificationRequest{ + Id: id, + }) + if err != nil { + return err + } + + fmt.Printf("Successfully deleted %d\n", id) + + return nil +} + +func Create(client pb.NetworkServiceClient, yamlBytes []byte) error { + input := &networkCreation{} + if err := yaml.Unmarshal(yamlBytes, &input); err != nil { + return err + } + + if err := input.Validate(); err != nil { + return err + } + + req := &pb.NetworkCreationRequest{ + InternetAccess: input.InternetAccess, + } + + resp, err := client.Create(context.Background(), req) + if err != nil { + return err + } + + fmt.Printf("Successfully created %d\n", resp.Id) + + return nil +} + +func Update(client pb.NetworkServiceClient, id uint32, yamlBytes []byte) error { + input := &networkCreation{} + if err := yaml.Unmarshal(yamlBytes, &input); err != nil { + return err + } + + if err := input.Validate(); err != nil { + return err + } + + req := &pb.NetworkUpdateRequest{ + Identification: &pb.NetworkIdentificationRequest{ + Id: id, + }, + Update: &pb.NetworkCreationRequest{ + InternetAccess: input.InternetAccess, + }, + } + + resp, err := client.Update(context.Background(), req) + if err != nil { + return err + } + + fmt.Printf("Successfully updated %d\n", resp.Id) + + return nil +} diff --git a/internal/cli/subnetwork/commands.go b/internal/cli/subnetwork/commands.go index 2c40c8e..d21aba0 100644 --- a/internal/cli/subnetwork/commands.go +++ b/internal/cli/subnetwork/commands.go @@ -1,161 +1,102 @@ package subnetwork import ( - "context" "fmt" "io" - "net" "os" - "text/tabwriter" "github.com/BenasB/bx2cloud/internal/api/pb" - "google.golang.org/protobuf/types/known/emptypb" - "gopkg.in/yaml.v3" + "github.com/BenasB/bx2cloud/internal/cli/common" + "github.com/BenasB/bx2cloud/internal/cli/exits" + "google.golang.org/grpc" ) -func newWriter() *tabwriter.Writer { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "id\tnetwork_id\tcidr\n") - return w -} - -func print(w *tabwriter.Writer, subnetwork *pb.Subnetwork) { - cidr := fmt.Sprintf("%d.%d.%d.%d/%d", - byte(subnetwork.Address>>24), - byte(subnetwork.Address>>16), - byte(subnetwork.Address>>8), - byte(subnetwork.Address), - subnetwork.PrefixLength) - - fmt.Fprintf(w, "%d\t%d\t%s\n", subnetwork.Id, subnetwork.NetworkId, cidr) -} - -func List(client pb.SubnetworkServiceClient) error { - stream, err := client.List(context.Background(), &emptypb.Empty{}) - if err != nil { - return err - } - - w := newWriter() - defer w.Flush() - for { - subnetwork, err := stream.Recv() - if err == io.EOF { - break - } - if err != nil { - return err - } - print(w, subnetwork) - } - - return nil -} - -func Get(client pb.SubnetworkServiceClient, id uint32) error { - subnetwork, err := client.Get(context.Background(), &pb.SubnetworkIdentificationRequest{ - Id: id, - }) - if err != nil { - return err - } - - w := newWriter() - defer w.Flush() - print(w, subnetwork) - - return nil -} - -func Delete(client pb.SubnetworkServiceClient, id uint32) error { - _, err := client.Delete(context.Background(), &pb.SubnetworkIdentificationRequest{ - Id: id, - }) - if err != nil { - return err - } - - fmt.Printf("Successfully deleted %d\n", id) - - return nil -} - -func Create(client pb.SubnetworkServiceClient, yamlBytes []byte) error { - input := &subnetworkCreation{} - if err := yaml.Unmarshal(yamlBytes, &input); err != nil { - return err - } - - if err := input.Validate(); err != nil { - return err - } - - _, ipNet, err := net.ParseCIDR(input.Cidr) - if err != nil { - return fmt.Errorf("Could not parse CIDR: %v", err) - } - - ip := ipNet.IP.To4() - if ip == nil { - return fmt.Errorf("Could not convert the ip to an IPv4 ip") - } - address := uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) - prefixLength, _ := ipNet.Mask.Size() - - req := &pb.SubnetworkCreationRequest{ - NetworkId: input.NetworkId, - Address: address, - PrefixLength: uint32(prefixLength), - } - - resp, err := client.Create(context.Background(), req) - if err != nil { - return err - } - - fmt.Printf("Successfully created %d\n", resp.Id) - - return nil -} - -func Update(client pb.SubnetworkServiceClient, id uint32, yamlBytes []byte) error { - input := &subnetworkCreation{} - if err := yaml.Unmarshal(yamlBytes, &input); err != nil { - return err - } - - if err := input.Validate(); err != nil { - return err - } - - _, ipNet, err := net.ParseCIDR(input.Cidr) - if err != nil { - return fmt.Errorf("Could not parse CIDR: %v", err) - } - - ip := ipNet.IP.To4() - if ip == nil { - return fmt.Errorf("Could not convert the ip to an IPv4 ip") - } - address := uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) - prefixLength, _ := ipNet.Mask.Size() - - req := &pb.SubnetworkUpdateRequest{ - Identification: &pb.SubnetworkIdentificationRequest{ - Id: id, +var Commands = []*common.CliCommand{ + common.NewCliSubcommand( + "subnetwork", + []*common.CliCommand{ + common.NewCliCommand( + "list", + "Retrieves all existing subnetworks", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewSubnetworkServiceClient(conn) + if err := List(client); err != nil { + return exits.SUBNETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "get", + "Retrieves a specified subnetwork", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewSubnetworkServiceClient(conn) + if err := List(client); err != nil { + return exits.SUBNETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "delete", + "Deletes a specified subnetwork", + "", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewSubnetworkServiceClient(conn) + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Delete(client, id); err != nil { + return exits.SUBNETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "create", + "Creates a new subnetwork resource", + "< file.yaml", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewSubnetworkServiceClient(conn) + + yamlBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return exits.SUBNETWORK_ERROR, err + } + + if err := Create(client, yamlBytes); err != nil { + return exits.SUBNETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), + common.NewCliCommand( + "update", + "Updates an existing network resource", + "< file.yaml", + func(args []string, conn *grpc.ClientConn) (exits.ExitCode, error) { + client := pb.NewSubnetworkServiceClient(conn) + + yamlBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return exits.SUBNETWORK_ERROR, err + } + + id, exitCode, err := common.ParseUint32Arg(&args) + if err != nil { + return exitCode, fmt.Errorf("failed to parse 'id' argument: %w", err) + } + + if err := Update(client, id, yamlBytes); err != nil { + return exits.SUBNETWORK_ERROR, err + } + return exits.SUCCESS, nil + }, + ), }, - Update: &pb.SubnetworkCreationRequest{ - Address: address, - PrefixLength: uint32(prefixLength), - }, - } - - resp, err := client.Update(context.Background(), req) - if err != nil { - return err - } - - fmt.Printf("Successfully updated %d\n", resp.Id) - - return nil + ), } diff --git a/internal/cli/subnetwork/handlers.go b/internal/cli/subnetwork/handlers.go new file mode 100644 index 0000000..2c40c8e --- /dev/null +++ b/internal/cli/subnetwork/handlers.go @@ -0,0 +1,161 @@ +package subnetwork + +import ( + "context" + "fmt" + "io" + "net" + "os" + "text/tabwriter" + + "github.com/BenasB/bx2cloud/internal/api/pb" + "google.golang.org/protobuf/types/known/emptypb" + "gopkg.in/yaml.v3" +) + +func newWriter() *tabwriter.Writer { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "id\tnetwork_id\tcidr\n") + return w +} + +func print(w *tabwriter.Writer, subnetwork *pb.Subnetwork) { + cidr := fmt.Sprintf("%d.%d.%d.%d/%d", + byte(subnetwork.Address>>24), + byte(subnetwork.Address>>16), + byte(subnetwork.Address>>8), + byte(subnetwork.Address), + subnetwork.PrefixLength) + + fmt.Fprintf(w, "%d\t%d\t%s\n", subnetwork.Id, subnetwork.NetworkId, cidr) +} + +func List(client pb.SubnetworkServiceClient) error { + stream, err := client.List(context.Background(), &emptypb.Empty{}) + if err != nil { + return err + } + + w := newWriter() + defer w.Flush() + for { + subnetwork, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return err + } + print(w, subnetwork) + } + + return nil +} + +func Get(client pb.SubnetworkServiceClient, id uint32) error { + subnetwork, err := client.Get(context.Background(), &pb.SubnetworkIdentificationRequest{ + Id: id, + }) + if err != nil { + return err + } + + w := newWriter() + defer w.Flush() + print(w, subnetwork) + + return nil +} + +func Delete(client pb.SubnetworkServiceClient, id uint32) error { + _, err := client.Delete(context.Background(), &pb.SubnetworkIdentificationRequest{ + Id: id, + }) + if err != nil { + return err + } + + fmt.Printf("Successfully deleted %d\n", id) + + return nil +} + +func Create(client pb.SubnetworkServiceClient, yamlBytes []byte) error { + input := &subnetworkCreation{} + if err := yaml.Unmarshal(yamlBytes, &input); err != nil { + return err + } + + if err := input.Validate(); err != nil { + return err + } + + _, ipNet, err := net.ParseCIDR(input.Cidr) + if err != nil { + return fmt.Errorf("Could not parse CIDR: %v", err) + } + + ip := ipNet.IP.To4() + if ip == nil { + return fmt.Errorf("Could not convert the ip to an IPv4 ip") + } + address := uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) + prefixLength, _ := ipNet.Mask.Size() + + req := &pb.SubnetworkCreationRequest{ + NetworkId: input.NetworkId, + Address: address, + PrefixLength: uint32(prefixLength), + } + + resp, err := client.Create(context.Background(), req) + if err != nil { + return err + } + + fmt.Printf("Successfully created %d\n", resp.Id) + + return nil +} + +func Update(client pb.SubnetworkServiceClient, id uint32, yamlBytes []byte) error { + input := &subnetworkCreation{} + if err := yaml.Unmarshal(yamlBytes, &input); err != nil { + return err + } + + if err := input.Validate(); err != nil { + return err + } + + _, ipNet, err := net.ParseCIDR(input.Cidr) + if err != nil { + return fmt.Errorf("Could not parse CIDR: %v", err) + } + + ip := ipNet.IP.To4() + if ip == nil { + return fmt.Errorf("Could not convert the ip to an IPv4 ip") + } + address := uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) + prefixLength, _ := ipNet.Mask.Size() + + req := &pb.SubnetworkUpdateRequest{ + Identification: &pb.SubnetworkIdentificationRequest{ + Id: id, + }, + Update: &pb.SubnetworkCreationRequest{ + Address: address, + PrefixLength: uint32(prefixLength), + }, + } + + resp, err := client.Update(context.Background(), req) + if err != nil { + return err + } + + fmt.Printf("Successfully updated %d\n", resp.Id) + + return nil +}