diff --git a/README.md b/README.md index e2e00be..6712b36 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A command-line interface (CLI) tool for Open Telekom Cloud (OTC) services. -## Features +## Features Overview - 🔐 **Authentication**: Browser-based SSO login with credential management - ☁️ **Multi-cloud Support**: Manage multiple cloud configurations via `clouds.yaml` @@ -10,7 +10,7 @@ A command-line interface (CLI) tool for Open Telekom Cloud (OTC) services. - 🐳 **CCE Operations**: List clusters and manage CCE (Cloud Container Engine) configurations - 🌍 **Multi-region**: Support for different regions and projects - 📁 **SFS Operations**: List of Scalable file systems (the Turbo variant) -- ⚖️ **ELB Operations**: List of load balancers and their values +- ⚖️ **ELB Operations**: List of load balancers and their values, delete and update ## Installation @@ -54,6 +54,18 @@ You can override configuration using environment variables with the `OTC_` prefi - `OTC_REGION`: Region to use - `OTC_PROJECT`: Project name +For non-interactive use (see [AK/SK Login](#aksk-login-cicd--automation) below), the full set of `OTC_`-prefixed variables understood by the underlying SDK includes: + +- `OTC_AUTH_URL`: Identity/IAM endpoint, e.g. `https://iam.eu-de.otc.t-systems.com/v3` +- `OTC_AK` / `OTC_ACCESS_KEY`: Access Key ID +- `OTC_SK` / `OTC_SECRET_KEY`: Secret Access Key +- `OTC_SECURITY_TOKEN`: Security token (only needed for temporary, not permanent, AK/SK pairs) +- `OTC_PROJECT_NAME` / `OTC_PROJECT_ID`: Project to scope the token to +- `OTC_REGION_NAME`: Region (e.g. `eu-de`) +- `OTC_AUTH_TYPE`: Set to `aksk` for AK/SK authentication + +> **Note:** if `OTC_CLOUD` is not set and a `clouds.yaml` exists (in the working directory, `~/.config/openstack/`, or `/etc/openstack/`), otc-cli silently falls back to that file's top-level `selected_cloud` entry — and any `ak`/`sk`/`security_token` stored there for that cloud take precedence over your exported env vars. On automation runners, make sure no stale `clouds.yaml` is present, and always set `OTC_CLOUD` explicitly to a name that does **not** appear in any file on the runner, so env-var auth can't be silently overridden by a leftover file-based credential. + ## Usage ### Authentication @@ -82,6 +94,40 @@ otc login \ --expiration 3600 ``` +### AK/SK Login (CI/CD & Automation) + +`otc login` requires an interactive browser and is not suitable for CI/CD pipelines (e.g. Bamboo). For automation, use a permanent AK/SK pair instead — no `clouds.yaml` or interactive login needed. + +**One-time setup on OTC:** + +1. Create a dedicated IAM user for the automation pipeline (do not reuse a personal/human account). +2. Attach a least-privilege custom policy/group granting only the permissions the pipeline needs (e.g. `list`/`show` on the services it queries). +3. Under that user, generate a permanent **Access Key (AK) / Secret Key (SK)** pair (IAM console → Access Keys). Permanent keys don't expire, can be individually disabled/deleted at any time to revoke access, and every API call made with them is attributable to that key in Cloud Trace Service (CTS) for auditing. + +**Usage:** export the credentials as environment variables and run commands directly — no config file required: + +```bash +export OTC_CLOUD=ci-automation # any name not present in a clouds.yaml on this runner +export OTC_AUTH_URL=https://iam.eu-de.otc.t-systems.com/v3 +export OTC_AUTH_TYPE=aksk +export OTC_AK= +export OTC_SK= +export OTC_PROJECT_NAME=eu-de_prod +export OTC_REGION_NAME=eu-de + +otc elb list +otc ecs list +``` + +## Features +### Identity + +Show which OTC domain, project, user and roles the current credentials (clouds.yaml, AK/SK env vars, etc.) resolve to — useful for verifying which account a Bamboo/CI job is actually authenticated as: + +```bash +otc whoami +``` + ### ECS (Elastic Cloud Server) List ECS instances from cloud and region specified in config files: @@ -114,6 +160,41 @@ otc elb list otc elb show ``` +Modify attributes (e.g. disable deletion protection before a delete): + +```bash +otc elb modify --deletion-protection-enabled=false +``` + +Delete a load balancer: + +```bash +otc elb delete +``` + +Required policy (list, modify attributes, and delete load balancers) — verified against OTC's IAM console (`Version` must be `1.1`; OTC does not register a `1.1`-schema `elb:loadbalancers:update` action, so the wildcard below is required for `otc elb modify` until a discrete action is confirmed): + +```json +{ + "Version": "1.1", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "elb:loadbalancers:list", + "elb:loadbalancers:get", + "elb:loadbalancers:delete", + "elb:loadbalancers:*" + ] + } + ] +} +``` + +- `elb:loadbalancers:list` / `get`: list and describe load balancers (`otc elb list`, `otc elb show`). +- `elb:loadbalancers:*`: covers modifying a load balancer (`otc elb modify`, e.g. `deletion_protection_enable`), since OTC has no separate registered `update` action. +- `elb:loadbalancers:delete`: delete a load balancer (`otc elb delete`). + ### CCE (Cloud Container Engine) List CCE clusters: diff --git a/cmd/elb_delete.go b/cmd/elb_delete.go new file mode 100644 index 0000000..2de361a --- /dev/null +++ b/cmd/elb_delete.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "fmt" + + "github.com/ysoftdevs/otc-cli/services/elb" + + "github.com/spf13/cobra" +) + +var elbDeleteCmd = &cobra.Command{ + Use: "delete NAME", + Short: "Delete an ELB load balancer", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := elb.Delete(args[0], commonConfig); err != nil { + return err + } + fmt.Printf("Load balancer %q deleted\n", args[0]) + return nil + }, +} + +func init() { + elbCmd.AddCommand(elbDeleteCmd) +} diff --git a/cmd/elb_list.go b/cmd/elb_list.go index 77b7e79..260ed04 100644 --- a/cmd/elb_list.go +++ b/cmd/elb_list.go @@ -1,6 +1,8 @@ package cmd import ( + "strconv" + "github.com/ysoftdevs/otc-cli/formats" "github.com/ysoftdevs/otc-cli/services/elb" @@ -47,6 +49,9 @@ func elbTableView() formats.View[elb.LoadBalancerInfo] { formats.Col("Public IPs", func(lb elb.LoadBalancerInfo) string { return elb.PublicIPsString(lb) }), + formats.Col("Deletion Protection", func(lb elb.LoadBalancerInfo) string { + return strconv.FormatBool(lb.DeletionProtectionEnable) + }), }, } } diff --git a/cmd/elb_modify.go b/cmd/elb_modify.go new file mode 100644 index 0000000..091a8b6 --- /dev/null +++ b/cmd/elb_modify.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + + "github.com/ysoftdevs/otc-cli/formats" + "github.com/ysoftdevs/otc-cli/services/elb" + + "github.com/spf13/cobra" +) + +var elbModifyDeletionProtectionEnabled bool + +var elbModifyCmd = &cobra.Command{ + Use: "modify NAME", + Short: "Modify attributes of an ELB load balancer", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("deletion-protection-enabled") { + return fmt.Errorf("specify at least one attribute to modify, e.g. --deletion-protection-enabled=false") + } + + lb, err := elb.SetDeletionProtection(args[0], elbModifyDeletionProtectionEnabled, commonConfig) + if err != nil { + return err + } + return formats.PrintFormatted(format, []elb.LoadBalancerInfo{*lb}, elbTableView()) + }, +} + +func init() { + elbCmd.AddCommand(elbModifyCmd) + elbModifyCmd.Flags().BoolVar(&elbModifyDeletionProtectionEnabled, "deletion-protection-enabled", false, "Enable or disable deletion protection") + initFlagFormat(elbModifyCmd) +} diff --git a/cmd/whoami.go b/cmd/whoami.go new file mode 100644 index 0000000..cd19d44 --- /dev/null +++ b/cmd/whoami.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "github.com/ysoftdevs/otc-cli/formats" + "github.com/ysoftdevs/otc-cli/services/identity" + + "github.com/spf13/cobra" +) + +var whoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "Show the OTC domain, project, user and roles for the current credentials", + RunE: func(cmd *cobra.Command, args []string) error { + info, err := identity.WhoAmI(commonConfig) + if err != nil { + return err + } + return formats.PrintFormatted(format, []identity.CallerIdentity{*info}, whoamiTableView()) + }, +} + +func init() { + rootCmd.AddCommand(whoamiCmd) + initFlagFormat(whoamiCmd) +} + +func whoamiTableView() formats.View[identity.CallerIdentity] { + return formats.View[identity.CallerIdentity]{ + Columns: []formats.Column[identity.CallerIdentity]{ + formats.Col("Domain ID", func(i identity.CallerIdentity) string { + return i.DomainID + }), + formats.Col("Domain Name", func(i identity.CallerIdentity) string { + return i.DomainName + }), + formats.Col("Project ID", func(i identity.CallerIdentity) string { + return i.ProjectID + }), + formats.Col("Project Name", func(i identity.CallerIdentity) string { + return i.ProjectName + }), + formats.Col("User ID", func(i identity.CallerIdentity) string { + return i.UserID + }), + formats.Col("User Name", func(i identity.CallerIdentity) string { + return i.UserName + }), + formats.Col("Access Key", func(i identity.CallerIdentity) string { + return i.AccessKey + }), + formats.Col("Roles", func(i identity.CallerIdentity) string { + return i.Roles + }), + }, + } +} diff --git a/services/elb/root.go b/services/elb/root.go index 6a19a00..440af09 100644 --- a/services/elb/root.go +++ b/services/elb/root.go @@ -13,11 +13,12 @@ import ( ) type LoadBalancerInfo struct { - ID string - Name string - Status string - VipAddress string - PublicIPs []string + ID string + Name string + Status string + VipAddress string + PublicIPs []string + DeletionProtectionEnable bool } func getELBClient(commonConfig *config.CommonConfig) (*golangsdk.ServiceClient, error) { @@ -74,6 +75,59 @@ func Show(name string, commonConfig *config.CommonConfig) (*LoadBalancerInfo, er return nil, fmt.Errorf("failed to create ELB client: %w", err) } + lb, err := findByName(elbClient, name) + if err != nil { + return nil, err + } + + info := toInfo(*lb) + return &info, nil +} + +// SetDeletionProtection enables or disables deletion protection on the named +// load balancer, e.g. to unblock a subsequent Delete call after OTC rejects +// it with ELB.8917 ("Deletion Protection ... is enable"). +func SetDeletionProtection(name string, enable bool, commonConfig *config.CommonConfig) (*LoadBalancerInfo, error) { + elbClient, err := getELBClient(commonConfig) + if err != nil { + return nil, fmt.Errorf("failed to create ELB client: %w", err) + } + + lb, err := findByName(elbClient, name) + if err != nil { + return nil, err + } + + updated, err := loadbalancers.Update(elbClient, lb.ID, loadbalancers.UpdateOpts{ + DeletionProtectionEnable: &enable, + }).Extract() + if err != nil { + return nil, fmt.Errorf("failed to update load balancer %q: %w", name, err) + } + + info := toInfo(*updated) + return &info, nil +} + +func Delete(name string, commonConfig *config.CommonConfig) error { + elbClient, err := getELBClient(commonConfig) + if err != nil { + return fmt.Errorf("failed to create ELB client: %w", err) + } + + lb, err := findByName(elbClient, name) + if err != nil { + return err + } + + err = loadbalancers.Delete(elbClient, lb.ID).ExtractErr() + if err != nil { + return fmt.Errorf("failed to delete load balancer %q: %w", name, err) + } + return nil +} + +func findByName(elbClient *golangsdk.ServiceClient, name string) (*loadbalancers.LoadBalancer, error) { pages, err := loadbalancers.List(elbClient, loadbalancers.ListOpts{ Name: []string{name}, }).AllPages() @@ -88,8 +142,7 @@ func Show(name string, commonConfig *config.CommonConfig) (*LoadBalancerInfo, er for _, lb := range lbs { if lb.Name == name { - info := toInfo(lb) - return &info, nil + return &lb, nil } } return nil, fmt.Errorf("load balancer %q not found", name) @@ -97,10 +150,11 @@ func Show(name string, commonConfig *config.CommonConfig) (*LoadBalancerInfo, er func toInfo(lb loadbalancers.LoadBalancer) LoadBalancerInfo { info := LoadBalancerInfo{ - ID: lb.ID, - Name: lb.Name, - Status: lb.OperatingStatus, - VipAddress: lb.VipAddress, + ID: lb.ID, + Name: lb.Name, + Status: lb.OperatingStatus, + VipAddress: lb.VipAddress, + DeletionProtectionEnable: lb.DeletionProtectionEnable, } for _, eip := range lb.Eips { if eip.EipAddress != "" { diff --git a/services/identity/root.go b/services/identity/root.go new file mode 100644 index 0000000..e16092a --- /dev/null +++ b/services/identity/root.go @@ -0,0 +1,144 @@ +package identity + +import ( + "fmt" + "strings" + + "github.com/ysoftdevs/otc-cli/client" + "github.com/ysoftdevs/otc-cli/config" + + golangsdk "github.com/opentelekomcloud/gophertelekomcloud" + "github.com/opentelekomcloud/gophertelekomcloud/openstack" + "github.com/opentelekomcloud/gophertelekomcloud/openstack/identity/v3/domains" + "github.com/opentelekomcloud/gophertelekomcloud/openstack/identity/v3/tokens" +) + +type CallerIdentity struct { + DomainID string + DomainName string + ProjectID string + ProjectName string + UserID string + UserName string + AccessKey string + Roles string +} + +// WhoAmI returns the domain, project, user and roles that the currently +// configured credentials (clouds.yaml, AK/SK env vars, etc.) authenticate as. +func WhoAmI(commonConfig *config.CommonConfig) (*CallerIdentity, error) { + opts, err := client.GetAuthOpts(commonConfig) + if err != nil { + return nil, err + } + + pc, err := client.GetAuthenticatedClient(opts) + if err != nil { + return nil, err + } + + // AK/SK auth never issues a bearer token (no POST /v3/auth/tokens ever + // happens), so pc.Token() is empty and GET /v3/auth/tokens always fails + // with a misleading 404 regardless of the credential's IAM permissions. + if pc.AKSKAuthOptions.AccessKey != "" { + return whoAmIFromAKSK(pc) + } + + return whoAmIFromToken(pc) +} + +// whoAmIFromAKSK reports identity for AK/SK-authenticated clients using only +// state already resolved during authentication, plus the self-service +// GET /v3/auth/domains call (lists domains the caller has access to), which +// requires no IAM permission beyond being an authenticated user. There is no +// reverse AK -> IAM user/username lookup available without extra +// (iam:credentials:get / iam:users:get) permissions, so User/Roles stay empty. +func whoAmIFromAKSK(pc *golangsdk.ProviderClient) (*CallerIdentity, error) { + info := &CallerIdentity{ + DomainID: pc.AKSKAuthOptions.DomainID, + ProjectID: pc.AKSKAuthOptions.ProjectId, + ProjectName: pc.AKSKAuthOptions.ProjectName, + AccessKey: pc.AKSKAuthOptions.AccessKey, + } + + if info.DomainID == "" { + if domainID, domainName, err := lookupOwnDomain(pc); err == nil { + info.DomainID = domainID + info.DomainName = domainName + } + } + + return info, nil +} + +func lookupOwnDomain(pc *golangsdk.ProviderClient) (string, string, error) { + identityClient, err := openstack.NewIdentityV3(pc, golangsdk.EndpointOpts{}) + if err != nil { + return "", "", err + } + identityClient.Endpoint += "auth/" + + pages, err := domains.List(identityClient, domains.ListOpts{}).AllPages() + if err != nil { + return "", "", fmt.Errorf("failed to list accessible domains: %w", err) + } + all, err := domains.ExtractDomains(pages) + if err != nil { + return "", "", fmt.Errorf("failed to extract domains: %w", err) + } + if len(all) != 1 { + return "", "", fmt.Errorf("expected exactly one accessible domain, got %d", len(all)) + } + return all[0].ID, all[0].Name, nil +} + +func whoAmIFromToken(pc *golangsdk.ProviderClient) (*CallerIdentity, error) { + identityClient, err := openstack.NewIdentityV3(pc, golangsdk.EndpointOpts{}) + if err != nil { + return nil, fmt.Errorf("failed to create identity client: %w", err) + } + + result := tokens.Get(identityClient, pc.Token()) + + user, err := result.ExtractUser() + if err != nil { + return nil, fmt.Errorf("failed to extract user from token: %w", err) + } + project, err := result.ExtractProject() + if err != nil { + return nil, fmt.Errorf("failed to extract project from token: %w", err) + } + domain, err := result.ExtractDomain() + if err != nil { + return nil, fmt.Errorf("failed to extract domain from token: %w", err) + } + roles, err := result.ExtractRoles() + if err != nil { + return nil, fmt.Errorf("failed to extract roles from token: %w", err) + } + + info := &CallerIdentity{} + if user != nil { + info.UserID = user.ID + info.UserName = user.Name + } + if project != nil { + info.ProjectID = project.ID + info.ProjectName = project.Name + // Project-scoped tokens carry the domain via the project. + info.DomainID = project.Domain.ID + info.DomainName = project.Domain.Name + } + if domain != nil { + info.DomainID = domain.ID + info.DomainName = domain.Name + } + + roleNames := make([]string, 0, len(roles)) + for _, r := range roles { + roleNames = append(roleNames, r.Name) + } + info.Roles = strings.Join(roleNames, ", ") + + return info, nil +}