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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

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`
- 🖥️ **ECS Management**: List and manage Elastic Cloud Servers
- 🐳 **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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=<access-key-id>
export OTC_SK=<secret-access-key>
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:
Expand Down Expand Up @@ -114,6 +160,41 @@ otc elb list
otc elb show <name>
```

Modify attributes (e.g. disable deletion protection before a delete):

```bash
otc elb modify <name> --deletion-protection-enabled=false
```

Delete a load balancer:

```bash
otc elb delete <name>
```

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:
Expand Down
26 changes: 26 additions & 0 deletions cmd/elb_delete.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 5 additions & 0 deletions cmd/elb_list.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"strconv"

"github.com/ysoftdevs/otc-cli/formats"
"github.com/ysoftdevs/otc-cli/services/elb"

Expand Down Expand Up @@ -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)
}),
},
}
}
35 changes: 35 additions & 0 deletions cmd/elb_modify.go
Original file line number Diff line number Diff line change
@@ -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)
}
56 changes: 56 additions & 0 deletions cmd/whoami.go
Original file line number Diff line number Diff line change
@@ -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
}),
},
}
}
76 changes: 65 additions & 11 deletions services/elb/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -88,19 +142,19 @@ 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)
}

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 != "" {
Expand Down
Loading
Loading