diff --git a/README.md b/README.md index 620a53b4..ef37dce1 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,17 @@ This opens your browser for authentication. Alternatively, provide a token file: unikraft login --token /path/to/token ``` -Or, if you need to directly specify a metro endpoint, you can manually create a -profile: +Or, you can manually create a profile and configure metros using the CLI: + +```sh +# Create a profile with a token and organization +unikraft profile create --name my-profile --token /path/to/token --organization my-org + +# Add a metro to the profile +unikraft metro create --name fra --endpoint https://api.fra.unikraft.cloud --country de +``` + +You can also directly edit the config file if you prefer: ```yaml # Linux: ~/.config/unikraft/config.yaml @@ -290,6 +299,12 @@ Manage multiple accounts or configurations with profiles: # List profiles unikraft profile list +# Create a new profile +unikraft profile create --name staging --token /path/to/token --organization my-org + +# Delete a profile +unikraft profile delete old-profile + # Switch profile unikraft profile use staging diff --git a/cmd/unikraft/auth_test.go b/cmd/unikraft/auth_test.go index 7eefd29a..2ce0d7be 100644 --- a/cmd/unikraft/auth_test.go +++ b/cmd/unikraft/auth_test.go @@ -16,9 +16,8 @@ func authTests(t *testing.T, r *testRunner) { {args: []string{unikraftCmd, "profile", "get", "--help"}}, {args: []string{unikraftCmd, "profile", "list", "--help"}}, {args: []string{unikraftCmd, "profile", "use", "--help"}}, - {args: []string{unikraftCmd, "metro", "--help"}}, - {args: []string{unikraftCmd, "metro", "get", "--help"}}, - {args: []string{unikraftCmd, "metro", "list", "--help"}}, + {args: []string{unikraftCmd, "profile", "create", "--help"}}, + {args: []string{unikraftCmd, "profile", "delete", "--help"}}, }) }) t.Run("flow", func(t *testing.T) { @@ -33,4 +32,61 @@ func authTests(t *testing.T, r *testRunner) { {args: []string{unikraftCmd, "metro", "list"}, allowErr: true}, }) }) + + t.Run("profile-create", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{unikraftCmd, "profile", "list"}}, + {args: []string{ + unikraftCmd, "profile", "create", + "--name", "test-profile", + "--token", "test-token", + "--organization", "test-org", + }}, + {args: []string{unikraftCmd, "profile", "list"}}, + {args: []string{unikraftCmd, "profile", "get", "test-profile"}}, + }) + }) + t.Run("profile-create-duplicate", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{ + unikraftCmd, "profile", "create", + "--name", "dup-profile", + "--token", "test-token", + }}, + {args: []string{ + unikraftCmd, "profile", "create", + "--name", "dup-profile", + "--token", "test-token-2", + }, allowErr: true}, + }) + }) + t.Run("profile-delete", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{ + unikraftCmd, "profile", "create", + "--name", "to-delete", + "--token", "test-token", + }}, + {args: []string{unikraftCmd, "profile", "list"}}, + {args: []string{unikraftCmd, "profile", "delete", "to-delete"}}, + {args: []string{unikraftCmd, "profile", "list"}}, + }) + }) + t.Run("profile-delete-active", func(t *testing.T) { + r. + online(). + run(t, []command{ + // The default profile from the online config is active; + // deleting it should fail. + {args: []string{unikraftCmd, "profile", "list"}}, + {args: []string{unikraftCmd, "profile", "delete", "default"}, allowErr: true}, + {args: []string{unikraftCmd, "profile", "list"}}, + }) + }) } diff --git a/cmd/unikraft/main_test.go b/cmd/unikraft/main_test.go index 37216762..68537a66 100644 --- a/cmd/unikraft/main_test.go +++ b/cmd/unikraft/main_test.go @@ -109,6 +109,7 @@ func TestGolden(t *testing.T) { }{ {"help", helpTests}, {"auth", authTests}, + {"metro", metroTests}, {"instances", instancesTests}, {"instance-templates", instanceTemplatesTests}, {"volumes", volumesTests}, @@ -461,6 +462,12 @@ var cleaners = []cleaner{ pattern: regexp.MustCompile(`/tmp/TestGolden[^/]+/`), repl: "/tmp/TestGolden/", }, + { + // quota field paths like "quotas.instances.active.used" vary between runs + // because concurrent resolution picks a non-deterministic field. + pattern: regexp.MustCompile(`\bresolving quotas\.[a-z.]+:`), + repl: "resolving quotas.FIELD:", + }, { // auto-generated domain names like "foo.ukp-stable.apw.unikraft.internal" pattern: regexp.MustCompile(`\.[a-z0-9.\-]+\.unikraft\.(app|internal)\b`), diff --git a/cmd/unikraft/metro_test.go b/cmd/unikraft/metro_test.go new file mode 100644 index 00000000..f18aa549 --- /dev/null +++ b/cmd/unikraft/metro_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2025, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package main + +import "testing" + +func metroTests(t *testing.T, r *testRunner) { + t.Run("help", func(t *testing.T) { + r.run(t, []command{ + {args: []string{unikraftCmd, "metro", "--help"}}, + {args: []string{unikraftCmd, "metro", "get", "--help"}}, + {args: []string{unikraftCmd, "metro", "list", "--help"}}, + {args: []string{unikraftCmd, "metro", "create", "--help"}}, + {args: []string{unikraftCmd, "metro", "edit", "--help"}}, + {args: []string{unikraftCmd, "metro", "delete", "--help"}}, + }) + }) + + t.Run("create", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{unikraftCmd, "metro", "list"}}, + // The create command resolves quotas/status against the + // endpoint. For a fake metro this fails, but the metro is + // still persisted. + {args: []string{ + unikraftCmd, "metro", "create", + "--set", "name=example", + "--set", "endpoint=https://api.example.unikraft.cloud", + "--set", "country=se", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "list"}}, + {args: []string{unikraftCmd, "metro", "get", "example", "-f", "name,country,endpoint"}}, + }) + }) + t.Run("create-shortcut", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{unikraftCmd, "metro", "list"}}, + {args: []string{ + unikraftCmd, "metro", "create", + "--name", "example", + "--endpoint", "https://api.example.unikraft.cloud", + "--country", "se", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "list"}}, + {args: []string{unikraftCmd, "metro", "get", "example", "-f", "name,country,endpoint"}}, + }) + }) + t.Run("create-duplicate", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{ + unikraftCmd, "metro", "create", + "--name", "example", + "--endpoint", "https://api.example.unikraft.cloud", + "--country", "se", + }, allowErr: true}, + {args: []string{ + unikraftCmd, "metro", "create", + "--name", "example", + "--endpoint", "https://api.example2.unikraft.cloud", + "--country", "se", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "list"}}, + }) + }) + + t.Run("edit", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{ + unikraftCmd, "metro", "create", + "--set", "name=example", + "--set", "endpoint=https://api.example.unikraft.cloud", + "--set", "country=se", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "get", "example", "-f", "name,country,endpoint"}}, + {args: []string{ + unikraftCmd, "metro", "edit", "example", + "--set", "endpoint=https://api.example2.unikraft.cloud", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "get", "example", "-f", "name,country,endpoint"}}, + }) + }) + t.Run("edit-shortcut", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{ + unikraftCmd, "metro", "create", + "--name", "example", + "--endpoint", "https://api.example.unikraft.cloud", + "--country", "se", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "get", "example", "-f", "name,country,endpoint"}}, + {args: []string{ + unikraftCmd, "metro", "edit", "example", + "--endpoint", "https://api.example2.unikraft.cloud", + "--country", "SE", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "get", "example", "-f", "name,country,endpoint"}}, + }) + }) + + t.Run("delete", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{ + unikraftCmd, "metro", "create", + "--set", "name=example", + "--set", "endpoint=https://api.example.unikraft.cloud", + "--set", "country=se", + }, allowErr: true}, + {args: []string{unikraftCmd, "metro", "list"}}, + {args: []string{unikraftCmd, "metro", "delete", "example"}}, + {args: []string{unikraftCmd, "metro", "list"}}, + }) + }) + t.Run("delete-nonexistent", func(t *testing.T) { + r. + online(). + run(t, []command{ + {args: []string{unikraftCmd, "metro", "delete", "nonexistent"}, allowErr: true}, + }) + }) +} diff --git a/cmd/unikraft/testdata/TestGolden/auth/flow b/cmd/unikraft/testdata/TestGolden/auth/flow index 057cdf46..3bd77383 100644 --- a/cmd/unikraft/testdata/TestGolden/auth/flow +++ b/cmd/unikraft/testdata/TestGolden/auth/flow @@ -6,8 +6,8 @@ stderr: $ unikraft profile list stdout: - NAME ACTIVE METROS - default true ["test"] + NAME ACTIVE ORGANIZATION METROS + default true test ["test"] $ unikraft metro list @@ -23,7 +23,7 @@ stderr: $ unikraft profile list stdout: - NAME ACTIVE METROS + NAME ACTIVE ORGANIZATION METROS $ unikraft metro list diff --git a/cmd/unikraft/testdata/TestGolden/auth/help b/cmd/unikraft/testdata/TestGolden/auth/help index 4c860309..54664220 100644 --- a/cmd/unikraft/testdata/TestGolden/auth/help +++ b/cmd/unikraft/testdata/TestGolden/auth/help @@ -79,12 +79,20 @@ stdout: Inspect a profile. list, ls List profiles. + delete, rm, remove + Remove a profile. + create + Create a profile. use Switch between profiles. Fields: name active + organization + token + control-plane + insecure metros Global flags: @@ -123,6 +131,10 @@ stdout: Fields: name active + organization + token + control-plane + insecure metros Flags: @@ -169,6 +181,10 @@ stdout: Fields: name active + organization + token + control-plane + insecure metros Flags: @@ -232,102 +248,41 @@ stdout: Toggle anonymous usage analytics. [default: true] -$ unikraft metro --help - -stdout: - Manage Unikraft Cloud metros. - - Usage: - unikraft metros [flags] - - Resources: - get, inspect, show - Inspect a metro. - list, ls - List metros. - - Fields: - name - country - endpoint - insecure - quotas, quotas.instances, quotas.instances.active, - quotas.instances.active.used, quotas.instances.active.limit, - quotas.instances.total, quotas.instances.total.used, - quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, - quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, - quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, - quotas.services, quotas.services.groups, quotas.services.groups.used, - quotas.services.groups.limit, quotas.services.exposed, - quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, - quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, - quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, - quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, - quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, - quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, - quotas.limits.volume.max, quotas.limits.autoscale, - quotas.limits.autoscale.min, quotas.limits.autoscale.max - status, status.ip, status.ping, status.online - - Global flags: - -h, --help - Show context-sensitive help. - --config= ($UNIKRAFT_CONFIG) - Path to the configuration file. - --log-level= ($UNIKRAFT_LOG_LEVEL) - Set the logging level. - [default: info, choices: trace, debug, info, warn, error, fatal] - --log-type= ($UNIKRAFT_LOG_TYPE) - Set the log type. - [default: text, choices: text, json] - --profile= ($UNIKRAFT_PROFILE) - Set the current profile. - --[no-]telemetry ($UNIKRAFT_TELEMETRY) - Toggle anonymous usage analytics. - [default: true] - -$ unikraft metro get --help +$ unikraft profile create --help stdout: - Inspect a metro. + Create a profile. Usage: - unikraft metros get [ ...] [flags] - - Arguments: - [ ...] - Target metros to get. + unikraft profile create [flags] Examples: - # Inspect a metro by name - unikraft metro get fra + # Create a new profile + unikraft profile create \ + --name staging \ + --token mytoken \ + --organization my-org Fields: name - country - endpoint + active + organization + token + control-plane insecure - quotas, quotas.instances, quotas.instances.active, - quotas.instances.active.used, quotas.instances.active.limit, - quotas.instances.total, quotas.instances.total.used, - quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, - quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, - quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, - quotas.services, quotas.services.groups, quotas.services.groups.used, - quotas.services.groups.limit, quotas.services.exposed, - quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, - quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, - quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, - quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, - quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, - quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, - quotas.limits.volume.max, quotas.limits.autoscale, - quotas.limits.autoscale.min, quotas.limits.autoscale.max - status, status.ip, status.ping, status.online + metros Flags: - -w, --watch= - Watch for changes and refresh output. Defaults to 2s. + --set==, --set-file== + Key-value pairs to set on the profile. + --visual + Open an editor to modify fields visually. + --cmd + Run a command to edit fields (receives YAML on stdin, outputs edited YAML on stdout). + --load, --save= + Load fields from a YAML file. + --dry-run + Print patches without applying them. -f, --field Specify which fields to include in the output. -o, --output @@ -350,58 +305,45 @@ stdout: Toggle anonymous usage analytics. [default: true] -$ unikraft metro list --help + Create flags: + -n, --name= + Profile name. + --token= + Authentication token. + --organization= + Organization name. + --controlplane= + Control plane URL. + [example: https://controlplane.unikraft.cloud] + -k, --insecure + Allow insecure connections. + +$ unikraft profile delete --help stdout: - List metros. + Remove a profile. Usage: - unikraft metros list [ ...] [flags] + unikraft profile delete ... [flags] Arguments: - [ ...] - Target metros to list. + ... + Target profiles to remove. Examples: - # List all metros - unikraft metro list - - # List metros with connection status - unikraft metro list -f +status - - # List metros with quota usage - unikraft metro list -f +quotas + # Delete a profile + unikraft profile delete staging Fields: name - country - endpoint + active + organization + token + control-plane insecure - quotas, quotas.instances, quotas.instances.active, - quotas.instances.active.used, quotas.instances.active.limit, - quotas.instances.total, quotas.instances.total.used, - quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, - quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, - quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, - quotas.services, quotas.services.groups, quotas.services.groups.used, - quotas.services.groups.limit, quotas.services.exposed, - quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, - quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, - quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, - quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, - quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, - quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, - quotas.limits.volume.max, quotas.limits.autoscale, - quotas.limits.autoscale.min, quotas.limits.autoscale.max - status, status.ip, status.ping, status.online + metros Flags: - --filter - Filter output based on a field value (e.g. --filter state==running). - -w, --watch= - Watch for changes and refresh output. Defaults to 2s. - --sort - Sort output by field values (e.g. --sort name,-timestamps.created-at). Use - prefix for descending, + for ascending. -f, --field Specify which fields to include in the output. -o, --output diff --git a/cmd/unikraft/testdata/TestGolden/auth/profile-create b/cmd/unikraft/testdata/TestGolden/auth/profile-create new file mode 100644 index 00000000..fcbd1922 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/auth/profile-create @@ -0,0 +1,25 @@ +$ unikraft profile list + +stdout: + NAME ACTIVE ORGANIZATION METROS + default true test ["test"] + +$ unikraft profile create --name test-profile --token test-token --organization test-org + +stdout: + name: test-profile + +$ unikraft profile list + +stdout: + NAME ACTIVE ORGANIZATION METROS + default true test ["test"] + test-profile false test-org + +$ unikraft profile get test-profile + +stdout: + name: test-profile + organization: test-org + insecure: false + metros: diff --git a/cmd/unikraft/testdata/TestGolden/auth/profile-create-duplicate b/cmd/unikraft/testdata/TestGolden/auth/profile-create-duplicate new file mode 100644 index 00000000..a6180656 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/auth/profile-create-duplicate @@ -0,0 +1,14 @@ +$ unikraft profile create --name dup-profile --token test-token + +stdout: + name: dup-profile + +$ unikraft profile create --name dup-profile --token test-token-2 + +stderr: + │ + │ error: + │ profile already exists: dup-profile + │ + +exit code: 1 diff --git a/cmd/unikraft/testdata/TestGolden/auth/profile-delete b/cmd/unikraft/testdata/TestGolden/auth/profile-delete new file mode 100644 index 00000000..08bfdd9c --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/auth/profile-delete @@ -0,0 +1,22 @@ +$ unikraft profile create --name to-delete --token test-token + +stdout: + name: to-delete + +$ unikraft profile list + +stdout: + NAME ACTIVE ORGANIZATION METROS + default true test ["test"] + to-delete false + +$ unikraft profile delete to-delete + +stdout: + to-delete + +$ unikraft profile list + +stdout: + NAME ACTIVE ORGANIZATION METROS + default true test ["test"] diff --git a/cmd/unikraft/testdata/TestGolden/auth/profile-delete-active b/cmd/unikraft/testdata/TestGolden/auth/profile-delete-active new file mode 100644 index 00000000..9acb2b36 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/auth/profile-delete-active @@ -0,0 +1,21 @@ +$ unikraft profile list + +stdout: + NAME ACTIVE ORGANIZATION METROS + default true test ["test"] + +$ unikraft profile delete default + +stderr: + │ + │ error: + │ cannot delete the active profile: default + │ + +exit code: 1 + +$ unikraft profile list + +stdout: + NAME ACTIVE ORGANIZATION METROS + default true test ["test"] diff --git a/cmd/unikraft/testdata/TestGolden/metro/create b/cmd/unikraft/testdata/TestGolden/metro/create new file mode 100644 index 00000000..ba566e85 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/create @@ -0,0 +1,29 @@ +$ unikraft metro list + +stdout: + NAME COUNTRY ENDPOINT + test xx https://api.unikraft.test + +$ unikraft metro create --set name=example --set endpoint=https://api.example.unikraft.cloud --set country=se + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro list + +stdout: + NAME COUNTRY ENDPOINT + test xx https://api.unikraft.test + example se https://api.example.unikraft.cloud + +$ unikraft metro get example -f name,country,endpoint + +stdout: + name: example + country: se + endpoint: https://api.example.unikraft.cloud diff --git a/cmd/unikraft/testdata/TestGolden/metro/create-duplicate b/cmd/unikraft/testdata/TestGolden/metro/create-duplicate new file mode 100644 index 00000000..d0f4bb94 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/create-duplicate @@ -0,0 +1,26 @@ +$ unikraft metro create --name example --endpoint https://api.example.unikraft.cloud --country se + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro create --name example --endpoint https://api.example2.unikraft.cloud --country se + +stderr: + │ + │ error: + │ metro already exists: example + │ + +exit code: 1 + +$ unikraft metro list + +stdout: + NAME COUNTRY ENDPOINT + test xx https://api.unikraft.test + example se https://api.example.unikraft.cloud diff --git a/cmd/unikraft/testdata/TestGolden/metro/create-shortcut b/cmd/unikraft/testdata/TestGolden/metro/create-shortcut new file mode 100644 index 00000000..a4e4eeb7 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/create-shortcut @@ -0,0 +1,29 @@ +$ unikraft metro list + +stdout: + NAME COUNTRY ENDPOINT + test xx https://api.unikraft.test + +$ unikraft metro create --name example --endpoint https://api.example.unikraft.cloud --country se + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro list + +stdout: + NAME COUNTRY ENDPOINT + test xx https://api.unikraft.test + example se https://api.example.unikraft.cloud + +$ unikraft metro get example -f name,country,endpoint + +stdout: + name: example + country: se + endpoint: https://api.example.unikraft.cloud diff --git a/cmd/unikraft/testdata/TestGolden/metro/delete b/cmd/unikraft/testdata/TestGolden/metro/delete new file mode 100644 index 00000000..1ea3b466 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/delete @@ -0,0 +1,27 @@ +$ unikraft metro create --set name=example --set endpoint=https://api.example.unikraft.cloud --set country=se + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro list + +stdout: + NAME COUNTRY ENDPOINT + test xx https://api.unikraft.test + example se https://api.example.unikraft.cloud + +$ unikraft metro delete example + +stdout: + example + +$ unikraft metro list + +stdout: + NAME COUNTRY ENDPOINT + test xx https://api.unikraft.test diff --git a/cmd/unikraft/testdata/TestGolden/metro/delete-nonexistent b/cmd/unikraft/testdata/TestGolden/metro/delete-nonexistent new file mode 100644 index 00000000..b544dec7 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/delete-nonexistent @@ -0,0 +1,9 @@ +$ unikraft metro delete nonexistent + +stderr: + │ + │ error: + │ metro not found: [nonexistent] + │ + +exit code: 1 diff --git a/cmd/unikraft/testdata/TestGolden/metro/edit b/cmd/unikraft/testdata/TestGolden/metro/edit new file mode 100644 index 00000000..33b5667f --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/edit @@ -0,0 +1,33 @@ +$ unikraft metro create --set name=example --set endpoint=https://api.example.unikraft.cloud --set country=se + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro get example -f name,country,endpoint + +stdout: + name: example + country: se + endpoint: https://api.example.unikraft.cloud + +$ unikraft metro edit example --set endpoint=https://api.example2.unikraft.cloud + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro get example -f name,country,endpoint + +stdout: + name: example + country: se + endpoint: https://api.example2.unikraft.cloud diff --git a/cmd/unikraft/testdata/TestGolden/metro/edit-shortcut b/cmd/unikraft/testdata/TestGolden/metro/edit-shortcut new file mode 100644 index 00000000..bfbaa311 --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/edit-shortcut @@ -0,0 +1,33 @@ +$ unikraft metro create --name example --endpoint https://api.example.unikraft.cloud --country se + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro get example -f name,country,endpoint + +stdout: + name: example + country: se + endpoint: https://api.example.unikraft.cloud + +$ unikraft metro edit example --endpoint https://api.example2.unikraft.cloud --country SE + +stderr: + │ + │ error: + │ resolving quotas.FIELD: error performing the request: Get "https://api.example.unikraft.cloud/v1/users/quotas": dial tcp: lookup api.example.unikraft.cloud: no such host + │ + +exit code: 1 + +$ unikraft metro get example -f name,country,endpoint + +stdout: + name: example + country: SE + endpoint: https://api.example2.unikraft.cloud diff --git a/cmd/unikraft/testdata/TestGolden/metro/help b/cmd/unikraft/testdata/TestGolden/metro/help new file mode 100644 index 00000000..e8b11baa --- /dev/null +++ b/cmd/unikraft/testdata/TestGolden/metro/help @@ -0,0 +1,425 @@ +$ unikraft metro --help + +stdout: + Manage Unikraft Cloud metros. + + Usage: + unikraft metros [flags] + + Resources: + get, inspect, show + Inspect a metro. + list, ls + List metros. + delete, rm, remove + Remove a metro. + create + Create a metro. + edit + Edit a metro. + + Fields: + name + country + endpoint + insecure + quotas, quotas.instances, quotas.instances.active, + quotas.instances.active.used, quotas.instances.active.limit, + quotas.instances.total, quotas.instances.total.used, + quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, + quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, + quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, + quotas.services, quotas.services.groups, quotas.services.groups.used, + quotas.services.groups.limit, quotas.services.exposed, + quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, + quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, + quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, + quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, + quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, + quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, + quotas.limits.volume.max, quotas.limits.autoscale, + quotas.limits.autoscale.min, quotas.limits.autoscale.max + status, status.ip, status.ping, status.online + + Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + +$ unikraft metro get --help + +stdout: + Inspect a metro. + + Usage: + unikraft metros get [ ...] [flags] + + Arguments: + [ ...] + Target metros to get. + + Examples: + # Inspect a metro by name + unikraft metro get fra + + Fields: + name + country + endpoint + insecure + quotas, quotas.instances, quotas.instances.active, + quotas.instances.active.used, quotas.instances.active.limit, + quotas.instances.total, quotas.instances.total.used, + quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, + quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, + quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, + quotas.services, quotas.services.groups, quotas.services.groups.used, + quotas.services.groups.limit, quotas.services.exposed, + quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, + quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, + quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, + quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, + quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, + quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, + quotas.limits.volume.max, quotas.limits.autoscale, + quotas.limits.autoscale.min, quotas.limits.autoscale.max + status, status.ip, status.ping, status.online + + Flags: + -w, --watch= + Watch for changes and refresh output. Defaults to 2s. + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + + Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + +$ unikraft metro list --help + +stdout: + List metros. + + Usage: + unikraft metros list [ ...] [flags] + + Arguments: + [ ...] + Target metros to list. + + Examples: + # List all metros + unikraft metro list + + # List metros with connection status + unikraft metro list -f +status + + # List metros with quota usage + unikraft metro list -f +quotas + + Fields: + name + country + endpoint + insecure + quotas, quotas.instances, quotas.instances.active, + quotas.instances.active.used, quotas.instances.active.limit, + quotas.instances.total, quotas.instances.total.used, + quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, + quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, + quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, + quotas.services, quotas.services.groups, quotas.services.groups.used, + quotas.services.groups.limit, quotas.services.exposed, + quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, + quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, + quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, + quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, + quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, + quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, + quotas.limits.volume.max, quotas.limits.autoscale, + quotas.limits.autoscale.min, quotas.limits.autoscale.max + status, status.ip, status.ping, status.online + + Flags: + --filter + Filter output based on a field value (e.g. --filter state==running). + -w, --watch= + Watch for changes and refresh output. Defaults to 2s. + --sort + Sort output by field values (e.g. --sort name,-timestamps.created-at). Use - prefix for descending, + for ascending. + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + + Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + +$ unikraft metro create --help + +stdout: + Create a metro. + + Usage: + unikraft metros create [flags] + + Examples: + # Create a new metro + unikraft metro create \ + --name fra \ + --endpoint https://api.fra.unikraft.cloud \ + --country de + + Fields: + name + country + endpoint + insecure + quotas, quotas.instances, quotas.instances.active, + quotas.instances.active.used, quotas.instances.active.limit, + quotas.instances.total, quotas.instances.total.used, + quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, + quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, + quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, + quotas.services, quotas.services.groups, quotas.services.groups.used, + quotas.services.groups.limit, quotas.services.exposed, + quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, + quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, + quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, + quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, + quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, + quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, + quotas.limits.volume.max, quotas.limits.autoscale, + quotas.limits.autoscale.min, quotas.limits.autoscale.max + status, status.ip, status.ping, status.online + + Flags: + --set==, --set-file== + Key-value pairs to set on the metro. + --visual + Open an editor to modify fields visually. + --cmd + Run a command to edit fields (receives YAML on stdin, outputs edited YAML on stdout). + --load, --save= + Load fields from a YAML file. + --dry-run + Print patches without applying them. + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + + Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + + Create flags: + -n, --name= + Metro name. + [examples: fra, sfo, nyc] + --endpoint= + Metro endpoint URL. + [example: https://api.fra.unikraft.cloud] + --country= + Country code. + [examples: de, us, gb] + +$ unikraft metro edit --help + +stdout: + Edit a metro. + + Usage: + unikraft metros edit [flags] + + Arguments: + + Target metro to edit. + + Examples: + # Update a metro endpoint + unikraft metro edit fra --endpoint https://api.fra.unikraft.cloud + + Fields: + name + country + endpoint + insecure + quotas, quotas.instances, quotas.instances.active, + quotas.instances.active.used, quotas.instances.active.limit, + quotas.instances.total, quotas.instances.total.used, + quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, + quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, + quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, + quotas.services, quotas.services.groups, quotas.services.groups.used, + quotas.services.groups.limit, quotas.services.exposed, + quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, + quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, + quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, + quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, + quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, + quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, + quotas.limits.volume.max, quotas.limits.autoscale, + quotas.limits.autoscale.min, quotas.limits.autoscale.max + status, status.ip, status.ping, status.online + + Flags: + --set==, --set-file== + Key-value pairs to set on the metro. + --add==, --add-file== + Key-value pairs to add to the metro. + --del==, --del-file== + Keys to delete from the metro. + --visual + Open an editor to modify fields visually. + --cmd + Run a command to edit fields (receives YAML on stdin, outputs edited YAML on stdout). + --load, --save= + Load fields from a YAML file. + --dry-run + Print patches without applying them. + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + + Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] + + Edit flags: + --endpoint= + Metro endpoint URL. + [example: https://api.fra.unikraft.cloud] + --country= + Country code. + [examples: de, us, gb] + +$ unikraft metro delete --help + +stdout: + Remove a metro. + + Usage: + unikraft metros delete ... [flags] + + Arguments: + ... + Target metros to remove. + + Examples: + # Delete a metro + unikraft metro delete fra + + Fields: + name + country + endpoint + insecure + quotas, quotas.instances, quotas.instances.active, + quotas.instances.active.used, quotas.instances.active.limit, + quotas.instances.total, quotas.instances.total.used, + quotas.instances.total.limit, quotas.vcpus, quotas.vcpus.active, + quotas.vcpus.active.used, quotas.vcpus.active.limit, quotas.memory, + quotas.memory.active, quotas.memory.active.used, quotas.memory.active.limit, + quotas.services, quotas.services.groups, quotas.services.groups.used, + quotas.services.groups.limit, quotas.services.exposed, + quotas.services.exposed.used, quotas.services.exposed.limit, quotas.volumes, + quotas.volumes.count, quotas.volumes.count.used, quotas.volumes.count.limit, + quotas.volumes.total, quotas.volumes.total.used, quotas.volumes.total.limit, + quotas.limits, quotas.limits.vcpus, quotas.limits.vcpus.min, + quotas.limits.vcpus.max, quotas.limits.memory, quotas.limits.memory.min, + quotas.limits.memory.max, quotas.limits.volume, quotas.limits.volume.min, + quotas.limits.volume.max, quotas.limits.autoscale, + quotas.limits.autoscale.min, quotas.limits.autoscale.max + status, status.ip, status.ping, status.online + + Flags: + -f, --field + Specify which fields to include in the output. + -o, --output + Output format. One of: kv, table, json, yaml, raw, quiet, template. + + Global flags: + -h, --help + Show context-sensitive help. + --config= ($UNIKRAFT_CONFIG) + Path to the configuration file. + --log-level= ($UNIKRAFT_LOG_LEVEL) + Set the logging level. + [default: info, choices: trace, debug, info, warn, error, fatal] + --log-type= ($UNIKRAFT_LOG_TYPE) + Set the log type. + [default: text, choices: text, json] + --profile= ($UNIKRAFT_PROFILE) + Set the current profile. + --[no-]telemetry ($UNIKRAFT_TELEMETRY) + Toggle anonymous usage analytics. + [default: true] diff --git a/internal/cmd/login/login.go b/internal/cmd/login/login.go index 47b9af6e..663c47b3 100644 --- a/internal/cmd/login/login.go +++ b/internal/cmd/login/login.go @@ -146,10 +146,7 @@ func (cmd *LoginCmd) Run(ctx context.Context, cfg *config.Config) error { // Save the profile cfg.DefaultProfile = profile.Name - if cfg.Profiles == nil { - cfg.Profiles = make(map[string]config.Profile) - } - cfg.Profiles[profile.Name] = *profile + cfg.AddProfile(*profile) if err := cfg.Save(); err != nil { return jujuerrors.Annotate(err, "saving profile") diff --git a/internal/cmd/metros.go b/internal/cmd/metros.go index d4c01e41..60e09f5f 100644 --- a/internal/cmd/metros.go +++ b/internal/cmd/metros.go @@ -7,11 +7,15 @@ package cmd import ( "context" + "fmt" "net" "net/http" "net/url" + "slices" "time" + "github.com/alecthomas/kong" + "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/httpclient" "unikraft.com/cli/internal/resource" @@ -28,12 +32,47 @@ type MetrosCmd struct { cmd.ResourceCmd[Metro] cmd.GettableResourceCmd[Metro] cmd.ListableResourceCmd[Metro] + cmd.DeletableResourceCmd[Metro] + + Create MetroCreateCmd `cmd:"" help:"Create a metro."` + Edit MetroEditCmd `cmd:"" help:"Edit a metro."` +} + +// MetroCreateCmd extends the generic create command with shortcut flags. +type MetroCreateCmd struct { + cmd.ResourceCreateCmd[Metro] + + Name string `group:"flag-create" shortcut:"name" short:"n" help:"Metro name." placeholder:"name" example:"fra,sfo,nyc"` + Endpoint string `group:"flag-create" shortcut:"endpoint" help:"Metro endpoint URL." placeholder:"url" example:"https://api.fra.unikraft.cloud"` + Country string `group:"flag-create" shortcut:"country" help:"Country code." placeholder:"code" example:"de,us,gb"` +} + +func (c *MetroCreateCmd) Run(ctx context.Context, stdio config.Stdio, sandbox *resource.Sandbox, kctx *kong.Context) error { + if err := cmd.ApplyShortcutFlags(&c.SetArgs, kctx.Flags()); err != nil { + return err + } + return c.ResourceCreateCmd.Run(ctx, stdio, sandbox) +} + +// MetroEditCmd extends the generic edit command with shortcut flags. +type MetroEditCmd struct { + cmd.ResourceEditCmd[Metro] + + Endpoint string `group:"flag-edit" shortcut:"endpoint" help:"Metro endpoint URL." placeholder:"url" example:"https://api.fra.unikraft.cloud"` + Country string `group:"flag-edit" shortcut:"country" help:"Country code." placeholder:"code" example:"de,us,gb"` +} + +func (c *MetroEditCmd) Run(ctx context.Context, stdio config.Stdio, sandbox *resource.Sandbox, kctx *kong.Context) error { + if err := cmd.ApplyShortcutFlags(&c.SetArgs, kctx.Flags()); err != nil { + return err + } + return c.ResourceEditCmd.Run(ctx, stdio, sandbox) } type Metro struct { - Name string `field:",short" json:"name"` - Country string `field:",short" json:"country"` - Endpoint string `field:",short" json:"endpoint"` + Name string `field:",short" json:"name" create:"set,required"` + Country string `field:",short" json:"country" create:"set" edit:"set"` + Endpoint string `field:",short" json:"endpoint" create:"set,required" edit:"set"` Insecure *bool `field:",long" json:"insecure"` } @@ -232,6 +271,111 @@ func (Metro) Get(ctx context.Context, keys []string) ([]resource.Resource, error return getFromListable(ctx, Metro{}, keys) } +func (Metro) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { + cfg := config.FromContextOrDefault(ctx) + profile, err := cfg.CurrentProfile() + if err != nil { + return nil, err + } + + var metro config.Metro + for key, field := range resource.IterFields(fields) { + if field.Create == nil || field.Create.Set == nil { + continue + } + switch key.String() { + case "name": + metro.Name = field.Create.Set.(string) + case "country": + metro.Country = field.Create.Set.(string) + case "endpoint": + metro.Endpoint = field.Create.Set.(string) + } + } + + for _, existing := range profile.Metros { + if existing.Name == metro.Name { + return nil, fmt.Errorf("metro already exists: %s", metro.Name) + } + } + + updated := *profile + updated.Metros = append(slices.Clone(updated.Metros), metro) + cfg.AddProfile(updated) + if err := cfg.Save(); err != nil { + return nil, err + } + + return []resource.Resource{Metro{ + Name: metro.Name, + Country: metro.Country, + Endpoint: metro.Endpoint, + }}, nil +} + +func (Metro) Edit(ctx context.Context, target resource.Resource, fields []resource.Field) (resource.Resource, error) { + cfg := config.FromContextOrDefault(ctx) + profile, err := cfg.CurrentProfile() + if err != nil { + return nil, err + } + + metro := target.(Metro) + updated := *profile + for i := range updated.Metros { + if updated.Metros[i].Name != metro.Name { + continue + } + for key, field := range resource.IterFields(fields) { + if field.Edit == nil || field.Edit.Set == nil { + continue + } + switch key.String() { + case "country": + updated.Metros[i].Country = field.Edit.Set.(string) + case "endpoint": + updated.Metros[i].Endpoint = field.Edit.Set.(string) + } + } + + cfg.AddProfile(updated) + if err := cfg.Save(); err != nil { + return nil, err + } + + return Metro{ + Name: updated.Metros[i].Name, + Country: updated.Metros[i].Country, + Endpoint: updated.Metros[i].Endpoint, + }, nil + } + + return nil, fmt.Errorf("metro not found: %s", metro.Name) +} + +func (Metro) Delete(ctx context.Context, targets []resource.Resource) error { + cfg := config.FromContextOrDefault(ctx) + profile, err := cfg.CurrentProfile() + if err != nil { + return err + } + + remove := make(map[string]struct{}, len(targets)) + for _, target := range targets { + metro := target.(Metro) + remove[metro.Name] = struct{}{} + } + + updated := *profile + updated.Metros = slices.DeleteFunc(slices.Clone(updated.Metros), func(metro config.Metro) bool { + _, ok := remove[metro.Name] + return ok + }) + + cfg.AddProfile(updated) + return cfg.Save() +} + func (Metro) Examples() map[cmd.CmdType][]kingkong.Example { return map[cmd.CmdType][]kingkong.Example{ cmd.CmdTypeGet: { @@ -254,6 +398,29 @@ func (Metro) Examples() map[cmd.CmdType][]kingkong.Example { Commands: []string{"unikraft metro list -f +quotas"}, }, }, + cmd.CmdTypeCreate: { + { + Description: "Create a new metro", + Commands: []string{ + `unikraft metro create \ + --name fra \ + --endpoint https://api.fra.unikraft.cloud \ + --country de`, + }, + }, + }, + cmd.CmdTypeEdit: { + { + Description: "Update a metro endpoint", + Commands: []string{"unikraft metro edit fra --endpoint https://api.fra.unikraft.cloud"}, + }, + }, + cmd.CmdTypeDelete: { + { + Description: "Delete a metro", + Commands: []string{"unikraft metro delete fra"}, + }, + }, } } diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index ec9a9408..93b77ab0 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -9,10 +9,12 @@ import ( "cmp" "context" "errors" + "fmt" "maps" "slices" "github.com/MakeNowJust/heredoc" + "github.com/alecthomas/kong" jujuerrors "github.com/juju/errors" "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/resource" @@ -26,13 +28,37 @@ type ProfileCmd struct { cmd.ResourceCmd[Profile] cmd.GettableResourceCmd[Profile] cmd.ListableResourceCmd[Profile] + cmd.DeletableResourceCmd[Profile] - Use UseCmd `cmd:"" help:"Switch between profiles."` + Create ProfileCreateCmd `cmd:"" help:"Create a profile."` + Use UseCmd `cmd:"" help:"Switch between profiles."` +} + +// ProfileCreateCmd extends the generic create command with shortcut flags. +type ProfileCreateCmd struct { + cmd.ResourceCreateCmd[Profile] + + Name string `group:"flag-create" shortcut:"name" short:"n" help:"Profile name." placeholder:"name"` + Token string `group:"flag-create" shortcut:"token" help:"Authentication token." placeholder:"token"` + Organization string `group:"flag-create" shortcut:"organization" help:"Organization name." placeholder:"org"` + ControlPlane string `group:"flag-create" name:"controlplane" shortcut:"control-plane" help:"Control plane URL." placeholder:"url" example:"https://controlplane.unikraft.cloud"` + Insecure *bool `group:"flag-create" shortcut:"insecure" short:"k" help:"Allow insecure connections."` +} + +func (c *ProfileCreateCmd) Run(ctx context.Context, stdio config.Stdio, sandbox *resource.Sandbox, kctx *kong.Context) error { + if err := cmd.ApplyShortcutFlags(&c.SetArgs, kctx.Flags()); err != nil { + return err + } + return c.ResourceCreateCmd.Run(ctx, stdio, sandbox) } type Profile struct { - Name string `field:",short" json:"name"` - Active bool `field:",short" json:"active"` + Name string `field:",short" json:"name" create:"set,required"` + Active bool `field:",short" json:"active"` + Organization string `field:",short" json:"organization" create:"set"` + Token string `field:",hidden" json:"token" create:"set,required"` + ControlPlane string `field:",long" json:"controlplane" create:"set"` + Insecure *bool `field:",long" json:"insecure" create:"set"` Metros []string `field:",short" json:"metros"` } @@ -68,9 +94,13 @@ func (Profile) List(ctx context.Context) ([]resource.Resource, error) { } result := Profile{ - Name: profile.Name, - Active: profile.Name == cfg.DefaultProfile, - Metros: metroNames, + Name: profile.Name, + Active: profile.Name == cfg.DefaultProfile, + Organization: profile.Organization, + Token: profile.Token, + ControlPlane: profile.ControlPlane, + Insecure: &profile.Insecure, + Metros: metroNames, } results = append(results, result) } @@ -84,6 +114,72 @@ func (Profile) Get(ctx context.Context, keys []string) ([]resource.Resource, err return getFromListable(ctx, Profile{}, keys) } +func (Profile) Create(ctx context.Context, fields []resource.Field) ([]resource.Resource, error) { + cfg := config.FromContextOrDefault(ctx) + + var name, token, organization, controlPlane string + var insecure *bool + for key, field := range resource.IterFields(fields) { + if field.Create == nil || field.Create.Set == nil { + continue + } + switch key.String() { + case "name": + name = field.Create.Set.(string) + case "token": + token = field.Create.Set.(string) + case "organization": + organization = field.Create.Set.(string) + case "control-plane": + controlPlane = field.Create.Set.(string) + case "insecure": + v := field.Create.Set.(bool) + insecure = &v + } + } + + if _, ok := cfg.Profiles[name]; ok { + return nil, fmt.Errorf("profile already exists: %s", name) + } + + profile := config.Profile{ + Name: name, + Type: config.ProfileTypeCloud, + Token: token, + Organization: organization, + ControlPlane: controlPlane, + Insecure: insecure != nil && *insecure, + } + cfg.AddProfile(profile) + // Set as default profile if this is the first one or no default is set. + if cfg.DefaultProfile == "" || len(cfg.Profiles) == 1 { + cfg.DefaultProfile = name + } + if err := cfg.Save(); err != nil { + return nil, err + } + + return []resource.Resource{Profile{ + Name: name, + Active: name == cfg.CurrentProfileName(), + }}, nil +} + +func (Profile) Delete(ctx context.Context, targets []resource.Resource) error { + cfg := config.FromContextOrDefault(ctx) + currentName := cfg.CurrentProfileName() + + for _, target := range targets { + p := target.(Profile) + if p.Name == currentName { + return fmt.Errorf("cannot delete the active profile: %s", p.Name) + } + delete(cfg.Profiles, p.Name) + } + + return cfg.Save() +} + func (Profile) Examples() map[cmd.CmdType][]kingkong.Example { return map[cmd.CmdType][]kingkong.Example{ cmd.CmdTypeGet: { @@ -98,6 +194,23 @@ func (Profile) Examples() map[cmd.CmdType][]kingkong.Example { Commands: []string{"unikraft profile list"}, }, }, + cmd.CmdTypeCreate: { + { + Description: "Create a new profile", + Commands: []string{ + `unikraft profile create \ + --name staging \ + --token mytoken \ + --organization my-org`, + }, + }, + }, + cmd.CmdTypeDelete: { + { + Description: "Delete a profile", + Commands: []string{"unikraft profile delete staging"}, + }, + }, } } diff --git a/internal/config/profile.go b/internal/config/profile.go index 1c6413e2..4ede15ad 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -225,3 +225,10 @@ func (config *Config) OverriddenCurrentProfile() (string, bool) { } return config.selectedProfile, true } + +func (config *Config) AddProfile(profile Profile) { + if config.Profiles == nil { + config.Profiles = make(map[string]Profile) + } + config.Profiles[profile.Name] = profile +}