Skip to content
Open
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
14 changes: 11 additions & 3 deletions .ai/spec/how/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ Audience: AI agents. This document describes **code layout, client wiring, and I

| File | Types | Key functions |
|------|-------|---------------|
| `root.go` | — | `NewRootCmd(streams)` — registers `ask`, `troubleshoot` (default mode dispatching), `config` subtree, and `version` |
| `root.go` | — | `NewRootCmd(streams)` — registers subcommands, default mode dispatching, global flags |
| `version.go` | Package var `Version` (default `dev`) | `NewVersionCmd(streams)` |
| `kubeconfig.go` | `KubeConfig` | `LoadKubeConfig(kubeconfigPath, contextName, insecureSkipTLS, caCertPath)` — bearer token extraction, TLS config |
| `ask.go` | `AskOptions` | `NewAskCmd`, `Complete`, `Validate`, `Run` — streams query in `ask` mode |
| `troubleshoot.go` | `TroubleshootOptions` | `NewTroubleshootCmd`, `Complete`, `Validate`, `Run` — streams query in `troubleshooting` mode |
| `streaming.go` | `SSEClient` | `NewSSEClient`, `StreamQuery` — shared HTTP + SSE streaming logic |
| `attachments.go` | — | `ReadAttachments(paths)` — reads files, builds attachment array |
| `render.go` | — | `RenderMarkdown(text)` — terminal markdown rendering via glamour |

*File names are planned. Update during implementation.*
*Implemented: `root.go`, `version.go`, `kubeconfig.go` (OLS-3632). Remaining files are planned.*

---

Expand Down Expand Up @@ -254,4 +255,11 @@ User invokes: oc ols "why is my pod crashing" --file pod.yaml

## Implementation notes

*Placeholder for findings during implementation. Update with actual file names, type signatures, and discovered constraints as implementation proceeds under OLS-1062.*
### OLS-3632: Scaffolding + kubeconfig integration

- `cmd/oc-ols/main.go` entry point follows oc-agentic pattern exactly (IOStreams → `NewRootCmd` → `Execute`)
- Global flags registered on root command: `--kubeconfig`, `--insecure-skip-tls-verify`, `--ca-cert`. `--endpoint` deferred to OLS-3633.
- `LoadKubeConfig` checks `restConfig.BearerToken` first, then `restConfig.BearerTokenFile`. Exec-based auth providers that populate tokens via transport wrappers (not `BearerToken` field) will be rejected — this is by design per the spec's "as long as `BearerToken` or `BearerTokenFile` is populated" requirement.
- TLS CA priority: `--ca-cert` flag > kubeconfig `CAData` > kubeconfig `CAFile`.
- New direct dependencies: `github.com/spf13/cobra`, `k8s.io/cli-runtime` (neither was in go.mod previously).
- Build: `go build -o bin/oc-ols ./cmd/oc-ols/`. No Makefile target yet (deferred to OLS-3640).
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ make test-e2e # E2E tests (requires cluster)

## Key File Locations

### CLI Plugin
- `cmd/oc-ols/main.go` - CLI binary entry point (IOStreams, root command)
- `cli/` - CLI command implementations (root, version, kubeconfig integration)

### Controllers
- `internal/controller/olsconfig_controller.go` - Main reconciler with finalizer logic
- `internal/controller/appserver/` - App server (also owns client CA Secrets `lightspeed-agentic-otel-ca` / `lightspeed-agentic-mcp-ca` / `lightspeed-agentic-rhokp-ca`; `RestartAppServer` calls `RefreshClientCASecrets` and touches the handoff ConfigMap)
Expand Down
119 changes: 119 additions & 0 deletions cli/kubeconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cli

import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"

"k8s.io/client-go/tools/clientcmd"
)

const (
ErrLoadKubeConfig = "failed to load kubeconfig"
ErrResolveContext = "failed to resolve kubeconfig context"
ErrReadTokenFile = "failed to read bearer token file"
ErrReadCACert = "failed to read CA certificate"
ErrInvalidCACert = "CA certificate contains no valid certificates"
ErrInvalidCAData = "kubeconfig CA data contains no valid certificates"
ErrNoBearerToken = "kubeconfig context does not provide a bearer token" //#nosec G101 -- error message, not a credential
)

// KubeConfig holds the resolved authentication and TLS configuration
// extracted from a kubeconfig file.
type KubeConfig struct {
BearerToken string
TLSConfig *tls.Config
ContextName string
}

// LoadKubeConfig reads a kubeconfig file, resolves the current context,
// and extracts the bearer token and TLS settings. oc-ols requires
// token-based auth — client-certificate-only contexts are rejected.
func LoadKubeConfig(kubeconfigPath string, contextName string, insecureSkipTLS bool, caCertPath string) (*KubeConfig, error) {
loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}
overrides := &clientcmd.ConfigOverrides{}
if contextName != "" {
overrides.CurrentContext = contextName
}

clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides)

rawConfig, err := clientConfig.RawConfig()
if err != nil {
return nil, fmt.Errorf("%s: %w", ErrLoadKubeConfig, err)
}

resolvedContext := rawConfig.CurrentContext
if contextName != "" {
resolvedContext = contextName
}

restConfig, err := clientConfig.ClientConfig()
if err != nil {
return nil, fmt.Errorf("%s %q: %w", ErrResolveContext, resolvedContext, err)
}

token := strings.TrimSpace(restConfig.BearerToken)
if token == "" && restConfig.BearerTokenFile != "" {
tokenBytes, err := os.ReadFile(restConfig.BearerTokenFile)
if err != nil {
return nil, fmt.Errorf("%s: %w", ErrReadTokenFile, err)
}
token = strings.TrimSpace(string(tokenBytes))
}

if token == "" {
return nil, fmt.Errorf(
"%s %q: oc-ols requires token-based authentication",
ErrNoBearerToken, resolvedContext,
)
}

tlsConfig := &tls.Config{
InsecureSkipVerify: insecureSkipTLS, //#nosec G402 -- user-controlled via --insecure-skip-tls-verify flag
ServerName: restConfig.ServerName,
}
Comment thread
xiormeesh marked this conversation as resolved.

if !insecureSkipTLS {
if caCertPath != "" {
pool, err := loadCACertPool(caCertPath)
if err != nil {
return nil, err
}
tlsConfig.RootCAs = pool
} else if len(restConfig.CAData) > 0 {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(restConfig.CAData) {
return nil, fmt.Errorf("%s", ErrInvalidCAData)
}
tlsConfig.RootCAs = pool
} else if restConfig.CAFile != "" {
pool, err := loadCACertPool(restConfig.CAFile)
if err != nil {
return nil, err
}
tlsConfig.RootCAs = pool
}
}

return &KubeConfig{
BearerToken: token,
TLSConfig: tlsConfig,
ContextName: resolvedContext,
}, nil
}

// loadCACertPool reads a PEM-encoded CA certificate file and returns a certificate pool.
func loadCACertPool(path string) (*x509.CertPool, error) {
caCert, err := os.ReadFile(path) //#nosec G304 -- path is user-controlled via --ca-cert flag or kubeconfig CAFile
if err != nil {
return nil, fmt.Errorf("%s %q: %w", ErrReadCACert, path, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("%s %q", ErrInvalidCACert, path)
}
return pool, nil
}
172 changes: 172 additions & 0 deletions cli/kubeconfig_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cli

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func writeTestKubeconfig(content string) string {
dir := GinkgoT().TempDir()
path := filepath.Join(dir, "kubeconfig")
Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed())
return path
}

const testKubeconfigWithToken = `
apiVersion: v1
kind: Config
current-context: test-ctx
clusters:
- cluster:
server: https://api.test.example.com:6443
name: test-cluster
contexts:
- context:
cluster: test-cluster
user: test-user
namespace: test-ns
name: test-ctx
users:
- name: test-user
user:
token: sha256~testtoken123
`

const testKubeconfigNoToken = `
apiVersion: v1
kind: Config
current-context: cert-ctx
clusters:
- cluster:
server: https://api.test.example.com:6443
name: test-cluster
contexts:
- context:
cluster: test-cluster
user: cert-user
name: cert-ctx
users:
- name: cert-user
user:
client-certificate-data: dGVzdA==
client-key-data: dGVzdA==
`

var _ = Describe("LoadKubeConfig", func() {
It("extracts the bearer token and context name", func() {
path := writeTestKubeconfig(testKubeconfigWithToken)
kc, err := LoadKubeConfig(path, "", false, "")
Expect(err).NotTo(HaveOccurred())
Expect(kc.BearerToken).To(Equal("sha256~testtoken123"))
Expect(kc.ContextName).To(Equal("test-ctx"))
})

It("returns an error for kubeconfig without bearer token", func() {
path := writeTestKubeconfig(testKubeconfigNoToken)
_, err := LoadKubeConfig(path, "", false, "")
Expect(err).To(HaveOccurred())
})

It("sets InsecureSkipVerify when requested", func() {
path := writeTestKubeconfig(testKubeconfigWithToken)
kc, err := LoadKubeConfig(path, "", true, "")
Expect(err).NotTo(HaveOccurred())
Expect(kc.TLSConfig.InsecureSkipVerify).To(BeTrue())
})

It("returns an error for nonexistent kubeconfig", func() {
_, err := LoadKubeConfig("/nonexistent/kubeconfig", "", false, "")
Expect(err).To(HaveOccurred())
})

It("loads a custom CA certificate", func() {
dir := GinkgoT().TempDir()
caPath := filepath.Join(dir, "ca.crt")

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
Expect(err).NotTo(HaveOccurred())
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{Organization: []string{"Test"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
Expect(err).NotTo(HaveOccurred())
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
Expect(os.WriteFile(caPath, certPEM, 0600)).To(Succeed())

path := writeTestKubeconfig(testKubeconfigWithToken)
kc, err := LoadKubeConfig(path, "", false, caPath)
Expect(err).NotTo(HaveOccurred())
Expect(kc.TLSConfig.RootCAs).NotTo(BeNil())
})

It("reads token from file and trims whitespace", func() {
dir := GinkgoT().TempDir()
tokenPath := filepath.Join(dir, "token")
Expect(os.WriteFile(tokenPath, []byte("file-based-token\n"), 0600)).To(Succeed())

kubeconfig := `
apiVersion: v1
kind: Config
current-context: sa-ctx
clusters:
- cluster:
server: https://api.test.example.com:6443
name: test-cluster
contexts:
- context:
cluster: test-cluster
user: sa-user
name: sa-ctx
users:
- name: sa-user
user:
tokenFile: ` + tokenPath + `
`
path := writeTestKubeconfig(kubeconfig)
kc, err := LoadKubeConfig(path, "", false, "")
Expect(err).NotTo(HaveOccurred())
Expect(kc.BearerToken).To(Equal("file-based-token"))
})

It("preserves tls-server-name from kubeconfig", func() {
kubeconfig := `
apiVersion: v1
kind: Config
current-context: sni-ctx
clusters:
- cluster:
server: https://api.test.example.com:6443
tls-server-name: custom-sni.example.com
name: test-cluster
contexts:
- context:
cluster: test-cluster
user: test-user
name: sni-ctx
users:
- name: test-user
user:
token: sha256~testtoken123
`
path := writeTestKubeconfig(kubeconfig)
kc, err := LoadKubeConfig(path, "", false, "")
Expect(err).NotTo(HaveOccurred())
Expect(kc.TLSConfig.ServerName).To(Equal("custom-sni.example.com"))
})
})
48 changes: 48 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Package cli implements the oc-ols kubectl plugin for querying OpenShift Lightspeed from the terminal.
package cli

import (
"fmt"

"github.com/spf13/cobra"
"k8s.io/cli-runtime/pkg/genericclioptions"
)

const (
ErrWriteOutput = "failed to write output"
)

// NewRootCmd creates the root oc-ols command and registers subcommands.
func NewRootCmd(streams genericclioptions.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "oc-ols [command]",
Short: "CLI for OpenShift Lightspeed",
Long: "Ask questions and troubleshoot OpenShift clusters using OpenShift Lightspeed.",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
if _, err := fmt.Fprintf(streams.ErrOut, "ask command not yet implemented\n"); err != nil {
return fmt.Errorf("%s: %w", ErrWriteOutput, err)
}
return nil
},
SilenceUsage: true,
Args: cobra.ArbitraryArgs,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

cmd.SetIn(streams.In)
cmd.SetOut(streams.Out)
cmd.SetErr(streams.ErrOut)

cmd.PersistentFlags().String("kubeconfig", "",
"Path to kubeconfig file (default: $KUBECONFIG or ~/.kube/config)")
cmd.PersistentFlags().Bool("insecure-skip-tls-verify", false,
"Skip TLS certificate verification")
cmd.PersistentFlags().String("ca-cert", "",
"Path to CA certificate for TLS verification")

cmd.AddCommand(NewVersionCmd(streams))

return cmd
}
Loading