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
5 changes: 2 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go-version: ['1.21', '1.22']

steps:
- name: Checkout code
Expand All @@ -24,7 +23,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
go-version-file: go.mod

- name: Build
run: go build -v -ldflags "-X github.com/ysoftdevs/otc-cli/cmd.Version=dev" ./...
Expand Down Expand Up @@ -60,7 +59,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version-file: go.mod

- name: Build binary
env:
Expand Down
188 changes: 183 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ sudo mv ~/Downloads/otc-darwin-arm64 /usr/local/bin/otc

Create a `clouds.yaml` file in your home directory (`~/.config/openstack/clouds.yaml`):

The file can contain short-lived authentication tokens after `otc login`, so it
must be readable only by the current user:

```bash
chmod 0600 ~/.config/openstack/clouds.yaml
```

On Unix, `otc login` enforces mode `0600` whenever it updates the file. Windows
does not use POSIX file modes; the file inherits the ACLs of the current user's
profile. Updates are written to a temporary file, synced, and atomically renamed
so an interrupted write does not truncate the existing configuration. If
`clouds.yaml` is a symbolic link, its target is updated without replacing the
link. On Unix, run the command above once to protect an existing file before its
next update.

```yaml
clouds:
my-cloud:
Expand All @@ -58,6 +73,43 @@ clouds:
expiration: 3600
```

For Entra ID / OpenID Connect federation, add an `oidc` block. This is the
preferred login mode when the selected OTC identity provider supports
programmatic OIDC access:

```yaml
clouds:
otc-dev-eu-de:
region_name: eu-de
auth:
auth_url: https://iam.eu-de.otc.t-systems.com/v3
project_name: eu-de_dev
domain_id: 57a0a4501de945d98fd366ab9dcf33cb
oidc:
tenant_id: <entra-tenant-id>
client_id: <entra-application-client-id>
idp: YS_OIDC_EID_DEV
scopes:
- openid
- profile
- email
```

The `oidc` block is optional. Clouds without `oidc` continue to use the legacy
`sso` configuration.

Legacy SAML/SSO login has important limitations:

- It depends on browser automation instead of a supported CLI token exchange.
- It is sensitive to the local browser implementation and can require a specific
browser/runtime setup.
- It relies on OTC console browser cookies to request temporary credentials.
- It is less portable across macOS/Linux environments than the OIDC flow.
- On macOS, the legacy default-browser credential extraction path supports
Safari only and is restricted to the standard `auth.otc.t-systems.com` and
`console.otc.t-systems.com` hosts. Custom `--url` or `sso.base_url` hosts are
rejected. Use OIDC for a browser-independent login flow.

### Environment Variables

You can override configuration using environment variables with the `OTC_` prefix:
Expand All @@ -82,16 +134,68 @@ For non-interactive use (see [AK/SK Login](#aksk-login-cicd--automation) below),

### Authentication

Login using browser-based SSO:
Login using the selected cloud configuration:

```bash
otc login
```

When the selected cloud has an `oidc` block, `otc login` opens the OS default
browser, completes Entra ID login using authorization code + PKCE, exchanges the
resulting ID token for an OTC Keystone token, scopes it to the configured
project, and stores that short-lived token in `clouds.yaml`.

The browser callback confirms only that Entra returned an authorization code.
The terminal reports the final result after the Entra and OTC token exchanges
complete.

On macOS, `otc login` uses the OS default browser by default. Safari is
supported for the OIDC default-browser flow without enabling Apple Events.

On Linux, OIDC default-browser login uses `xdg-open`. On Windows, it uses the
system URL handler.

For OIDC login, you can choose which browser opens the Entra login URL:

```bash
otc login --browser firefox
```

The `--browser` flag is only an opener for OIDC login. It does not automate the
browser, read cookies, or execute JavaScript in Safari/Firefox/Chrome. Legacy
SAML/SSO login does not support `--browser`.

#### Hosts without a browser

The default OIDC flow needs a browser that can reach a callback listener on the
same machine, so it does not work over plain SSH on a headless host. `otc login`
always prints the sign-in URL, but on such a host use the device-code flow
instead:

```bash
otc login --device-code
```

This prints a short code to enter at a Microsoft verification URL from any other
device, and needs no local browser, no callback listener and no port forwarding.
It is still an interactive user login, intended for jump hosts and containers.
It is not workload identity for unattended CI/CD; use a dedicated workload
identity or service credential for automation.

With specific cloud configuration:

```bash
otc login --cloud my-cloud --domain-id YOUR_DOMAIN_ID
otc login --cloud my-cloud --domain-id YOUR_DOMAIN_ID --idp YOUR_IDP
```

If the selected cloud does not include SSO settings in `clouds.yaml`, pass them
explicitly:

```bash
otc login \
--cloud my-cloud \
--domain-id YOUR_DOMAIN_ID \
--idp YOUR_IDP
```

Custom authentication parameters:
Expand Down Expand Up @@ -303,26 +407,100 @@ These flags are available for all commands:
- `-c, --cloud`: Name of the cloud from clouds.yaml to use
- `-r, --region`: Region to use for the cloud
- `-p, --project`: Project name to use for authentication
- `-f, --format`: Changes output style, possible options: `yaml, json, table, value`. Defaults to `table`.
- `-f, --format`: Output format for formatted commands, possible options: `table`, `json`, `yaml`. Defaults to `table`.

## Login Flags

- `--debug`: Print OIDC login diagnostics without printing token values.
- `--browser`: Browser to open for OIDC login. Defaults to the OS default
browser.
- `--device-code`: Use the OIDC device-code flow instead of opening a browser.
This is an interactive, OIDC-only flow for hosts without a browser and cannot
be combined with an explicit `--browser`.

## Development

### Prerequisites

- Go 1.21 or higher
- Go 1.24 or higher

### Building

Compile everything for the current machine:

```bash
go build -v ./...
```

Produce a runnable `otc` binary:

```bash
go build -o otc .
```

#### Stamping the version

`otc --version` reads `cmd.Version`, which defaults to `dev`. Set it at link
time:

```bash
go build -ldflags "-X github.com/ysoftdevs/otc-cli/cmd.Version=$(git describe --tags --always)" -o otc .
```

#### Cross-compiling

The binary is pure Go with no cgo dependency, so cross-compiling needs nothing
but `GOOS` and `GOARCH` — no toolchain, no C compiler:

```bash
GOOS=darwin GOARCH=arm64 go build -o dist/otc-darwin-arm64 . # Apple Silicon
GOOS=darwin GOARCH=amd64 go build -o dist/otc-darwin-amd64 . # Intel Mac
GOOS=linux GOARCH=arm64 go build -o dist/otc-linux-arm64 . # ARM64 Linux
GOOS=linux GOARCH=amd64 go build -o dist/otc-linux-amd64 . # x86-64 Linux
GOOS=windows GOARCH=arm64 go build -o dist/otc-windows-arm64.exe . # ARM64 Windows
GOOS=windows GOARCH=amd64 go build -o dist/otc-windows-amd64.exe . # x86-64 Windows
```

All six targets in one go, version-stamped and size-reduced:

```bash
VERSION=$(git describe --tags --always)
LDFLAGS="-s -w -X github.com/ysoftdevs/otc-cli/cmd.Version=$VERSION"
for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64 windows/arm64 windows/amd64; do
GOOS=${target%/*} GOARCH=${target#*/} \
go build -ldflags "$LDFLAGS" -o "dist/otc-${target%/*}-${target#*/}$([ "${target%/*}" = windows ] && printf .exe)" .
done
```

Note that the loop above is written for `bash`. In `zsh` — the default shell on
macOS — an unquoted `$target` is not word-split, so build each target with its
own command or run the loop under `bash`.

Useful build flags:

| Flag | Effect |
|------|--------|
| `-ldflags "-s -w"` | Strips the symbol table and DWARF data, roughly 15% smaller binary (13.2 MB to 11.3 MB) |
| `-ldflags "-X <pkg>.Version=..."` | Sets the version reported by `otc --version` |
| `-trimpath` | Removes local filesystem paths, making builds reproducible |
| `-o <path>` | Output file rather than the default package name |

Platform-specific code is selected by build tags, so a cross-compiled binary
contains only the relevant legacy login backend:
`system_browser_darwin.go`, `system_browser_linux.go`, or
`system_browser_windows.go`. Compiling on one OS therefore does not type-check
the others; cross-build every release target before publishing.

### Running Tests

```bash
go test -v ./...
```

Because of the build tags above, `go test` only exercises the backend for the
host OS. To check the other one, either cross-compile it as shown above or run
the suite on that platform.

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.
Contributions are welcome! Please feel free to submit a Pull Request.
1 change: 0 additions & 1 deletion cmd/cce_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ var listCmd = &cobra.Command{

func init() {
cceCmd.AddCommand(listCmd)
initFlagFormat(listCmd)
}

func clustersTableView() formats.View[cce.Cluster] {
Expand Down
1 change: 0 additions & 1 deletion cmd/ecs_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ func init() {
ecsListCmd.Flags().StringVar(&ecsListArgs.Filter, "filter", ecsListArgs.Filter, "Filter servers by name")
ecsListCmd.Flags().StringVar(&ecsListArgs.Status, "status", ecsListArgs.Status, "Filter servers by status (e.g. ACTIVE)")
ecsListCmd.Flags().IntVar(&ecsListArgs.Limit, "limit", ecsListArgs.Limit, "Limit the number of servers listed")
initFlagFormat(ecsListCmd)
}

func serversTableView() formats.View[servers.Server] {
Expand Down
1 change: 0 additions & 1 deletion cmd/ecs_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ var ecsShowCmd = &cobra.Command{

func init() {
ecsCmd.AddCommand(ecsShowCmd)
initFlagFormat(ecsShowCmd)
}

func extractAddresses(raw map[string]interface{}) string {
Expand Down
1 change: 0 additions & 1 deletion cmd/elb_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ var elbListArgs = elb.ListArgs{
func init() {
elbCmd.AddCommand(elbListCmd)
elbListCmd.Flags().StringVar(&elbListArgs.Filter, "filter", "", "Filter load balancers by name")
initFlagFormat(elbListCmd)
}

func elbTableView() formats.View[elb.LoadBalancerInfo] {
Expand Down
1 change: 0 additions & 1 deletion cmd/elb_modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,4 @@ var elbModifyCmd = &cobra.Command{
func init() {
elbCmd.AddCommand(elbModifyCmd)
elbModifyCmd.Flags().BoolVar(&elbModifyDeletionProtectionEnabled, "deletion-protection-enabled", false, "Enable or disable deletion protection")
initFlagFormat(elbModifyCmd)
}
1 change: 0 additions & 1 deletion cmd/elb_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@ var elbShowCmd = &cobra.Command{

func init() {
elbCmd.AddCommand(elbShowCmd)
initFlagFormat(elbShowCmd)
}
8 changes: 7 additions & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ var loginCmd = &cobra.Command{
config.SetIfEmpty(&loginArgs.Idp, cloud.SSO.Idp)
config.SetIfEmpty(&loginArgs.BaseURL, cloud.SSO.BaseURL)
config.SetIfZero(&loginArgs.Expiration, cloud.SSO.Expiration)

loginArgs.OIDC = cloud.OIDC
}

return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := login.BrowserLogin(loginArgs); err != nil {
if err := login.Login(loginArgs); err != nil {
return fmt.Errorf("error during login: %w", err)
}
return nil
Expand All @@ -39,6 +41,7 @@ var loginArgs = login.LoginArgs{
AuthURL: "https://iam.eu-de.otc.t-systems.com/v3",
Protocol: "saml",
Expiration: 3600,
Browser: "default",
CommonConfig: commonConfig,
}

Expand All @@ -51,4 +54,7 @@ func init() {
loginCmd.Flags().StringVar(&loginArgs.Idp, "idp", loginArgs.Idp, "Identity provider")
loginCmd.Flags().StringVar(&loginArgs.Protocol, "protocol", loginArgs.Protocol, "Authentication protocol")
loginCmd.Flags().IntVar(&loginArgs.Expiration, "expiration", loginArgs.Expiration, "Credential expiration time in seconds")
loginCmd.Flags().StringVar(&loginArgs.Browser, "browser", loginArgs.Browser, "Browser to open for OIDC login. Use default, or a browser name such as safari, firefox, or chromium")
loginCmd.Flags().BoolVar(&loginArgs.DeviceCode, "device-code", loginArgs.DeviceCode, "Use the interactive OIDC device-code flow on a host without a browser")
loginCmd.Flags().BoolVar(&loginArgs.Debug, "debug", loginArgs.Debug, "Print login debug information without credential values")
}
1 change: 0 additions & 1 deletion cmd/rds_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ func init() {

rdsListCmd.Flags().StringVar(&rdsListArgs.Opts.Name, "filter", ecsListArgs.Filter, "Filter instances by name")
rdsListCmd.Flags().IntVar(&rdsListArgs.Opts.Limit, "limit", ecsListArgs.Limit, "Limit the number of instances listed")
initFlagFormat(rdsListCmd)
}

func rdsInstancesTableView() formats.View[instances.InstanceResponse] {
Expand Down
22 changes: 17 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"

"github.com/ysoftdevs/otc-cli/config"
"github.com/ysoftdevs/otc-cli/formats"

"github.com/spf13/cobra"
)
Expand All @@ -19,7 +20,21 @@ var rootCmd = &cobra.Command{
Version: Version,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return commonConfig.AugmentFromFiles()
if err := formats.Validate(format); err != nil {
return err
}
if err := commonConfig.AugmentFromFiles(); err != nil {
return err
}
// Only enforce that the cloud actually exists in clouds.yaml when the
// user explicitly asked for it via --cloud. A cloud name coming from
// OTC_CLOUD or clouds.yaml's selected_cloud is commonly just a label
// used alongside env-based auth (e.g. OTC_AK/OTC_SK) with no matching
// clouds.yaml entry, which is a supported way to authenticate.
if cmd.Flags().Changed("cloud") {
return commonConfig.RequireCloudFound()
}
return nil
},
}

Expand All @@ -43,8 +58,5 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&commonConfig.CloudName, "cloud", "c", "", "Name of the cloud from clouds.yaml to use")
rootCmd.PersistentFlags().StringVarP(&commonConfig.Region, "region", "r", "", "Region to use for the cloud")
rootCmd.PersistentFlags().StringVarP(&commonConfig.ProjectName, "project", "p", "", "Project name to use for authentication")
}

func initFlagFormat(cmd *cobra.Command) {
cmd.Flags().StringVarP(&format, "format", "f", "table", "Output format: table, json, yaml")
rootCmd.PersistentFlags().StringVarP(&format, "format", "f", "table", "Output format: table, json, yaml")
}
Loading
Loading