This document proposes a Go-based architecture for dtctl, leveraging the same technology stack as kubectl. Go provides excellent CLI tooling, cross-platform compilation, and a mature ecosystem for building robust command-line applications.
Rationale:
- kubectl compatibility: Uses the same language and many of the same libraries as kubectl
- Single binary distribution: Compiles to a single, statically-linked binary with no runtime dependencies
- Cross-platform: Native compilation for Linux, macOS, Windows (AMD64, ARM64)
- Performance: Fast startup time, low memory footprint
- Strong ecosystem: Excellent libraries for CLI development, HTTP clients, and OpenAPI
- Concurrency: Built-in goroutines for parallel operations (e.g., fetching multiple resources)
Repository: https://github.com/spf13/cobra
Features:
- Hierarchical command structure (verb-noun pattern)
- Automatic help generation
- Shell completion (bash, zsh, fish, powershell)
- Persistent and local flags
- Aliases and command suggestions
- Used by: kubectl, GitHub CLI, Hugo, Docker CLI
Usage:
// Root command
rootCmd := &cobra.Command{
Use: "dtctl",
Short: "Dynatrace platform CLI",
}
// Subcommands
getCmd := &cobra.Command{Use: "get"}
applyCmd := &cobra.Command{Use: "apply"}
// Resource-specific commands
getDocumentsCmd := &cobra.Command{
Use: "documents [id]",
Aliases: []string{"document", "doc"},
Run: getDocumentsHandler,
}Repository: https://github.com/spf13/viper
Features:
- Configuration file support (YAML, JSON, TOML)
- Environment variable binding
- Default values
- Live watching and re-reading of config files
- Reading from remote config systems (optional)
Usage:
// Load config following XDG Base Directory spec
// $XDG_CONFIG_HOME/dtctl/config (default: ~/.config/dtctl/config)
viper.SetConfigName("config")
viper.AddConfigPath(config.ConfigDir())
// Bind flags to config
viper.BindPFlag("context", cmd.Flags().Lookup("context"))Package: pkg/watch
Architecture:
- Watcher: Core polling engine with configurable intervals (minimum 1s, default 2s)
- Differ: Change detection algorithm using map-based comparison
- WatchPrinter: Output formatter with kubectl-style change indicators
Features:
- Incremental change display (additions, modifications, deletions)
- Graceful shutdown on Ctrl+C via context cancellation
- Error handling for transient failures, rate limiting, and network issues
- Memory-efficient (only stores last state, not full history)
- Works with all
getcommands and DQL queries
Change Indicators:
+(green) for added resources~(yellow) for modified resources-(red) for deleted resources
Flags:
--watch: Enable watch mode--interval: Polling interval (default: 2s, min: 1s)--watch-only: Skip initial state display
Repository: https://github.com/deepmap/oapi-codegen
Rationale:
- Type-safe API clients
- Supports all HTTP methods and authentication schemes
- Generates models for request/response bodies
- Lightweight generated code
Alternative considered: go-swagger (more heavyweight, OpenAPI 2.0 focused)
Repository: https://github.com/go-resty/resty
Features:
- Built on top of Go's standard
net/http - Automatic retry with exponential backoff
- Request/response middleware
- Debug logging
- Multipart form data
- Automatic marshaling/unmarshaling
Usage:
client := resty.New().
SetBaseURL(baseURL).
SetAuthToken(token).
SetRetryCount(3).
SetRetryWaitTime(1 * time.Second).
SetRetryMaxWaitTime(10 * time.Second).
AddRetryCondition(func(r *resty.Response, err error) bool {
return r.StatusCode() == 429 // Retry on rate limit
})Repository:
- https://github.com/olekukonko/tablewriter
- https://github.com/pterm/pterm (more modern, colorful)
Features:
- ASCII table formatting
- Column alignment
- Header formatting
- Border customization
- Color support (pterm)
Usage:
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Name", "Type", "Owner", "Modified"})
table.AppendBulk(rows)
table.Render()Packages:
encoding/json(standard library)gopkg.in/yaml.v3for YAML
Repository: Part of k8s.io/client-go
Features:
- kubectl-compatible JSONPath syntax
- Field selection from structured output
Standard: no-color.org
Decision logic:
Color enabled = NOT (NO_COLOR is set) AND NOT (--plain flag) AND (stdout is a TTY OR FORCE_COLOR=1)
Implementation (pkg/output/styles.go):
ColorEnabled()— returns whether ANSI color output is enabled (cached withsync.Once)Colorize(text, colorCode)— wraps text in ANSI escape codes only when color is enabledColorCode(code)— returns the ANSI code string or empty string when color is disabledResetColorCache()— resets thesync.Oncecache (for testing only)- TTY detection uses
golang.org/x/term.IsTerminal()
Environment variables:
NO_COLOR(any non-empty value) — disables color outputFORCE_COLOR=1— overrides TTY detection to force color on
Implementation:
- YAML-based configuration file
- Store contexts, environments, tokens
- Current context pointer
- Use
gopkg.in/yaml.v3for serialization
Structure:
type Config struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
CurrentContext string `yaml:"current-context"`
Contexts []NamedContext `yaml:"contexts"`
Tokens []NamedToken `yaml:"tokens"`
Preferences Preferences `yaml:"preferences"`
}
type NamedContext struct {
Name string `yaml:"name"`
Context Context `yaml:"context"`
}
type Context struct {
Environment string `yaml:"environment"`
TokenRef string `yaml:"token-ref"`
}XDG Directory Structure:
dtctl follows the XDG Base Directory Specification using the github.com/adrg/xdg library for cross-platform compatibility:
-
Config Directory: Stores configuration files
- Linux:
$XDG_CONFIG_HOME/dtctl(default:~/.config/dtctl) - macOS:
~/Library/Application Support/dtctl - Windows:
%LOCALAPPDATA%\dtctl
- Linux:
-
Data Directory: For application data (query libraries, templates)
- Linux:
$XDG_DATA_HOME/dtctl(default:~/.local/share/dtctl) - macOS:
~/Library/Application Support/dtctl - Windows:
%LOCALAPPDATA%\dtctl
- Linux:
-
Cache Directory: For temporary cached data
- Linux:
$XDG_CACHE_HOME/dtctl(default:~/.cache/dtctl) - macOS:
~/Library/Caches/dtctl - Windows:
%LOCALAPPDATA%\dtctl
- Linux:
Repository: https://github.com/go-playground/validator
Features:
- Struct field validation
- Custom validation functions
- OpenAPI schema validation
import "testing"
func TestGetDocuments(t *testing.T) {
// Test implementation
}server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(mockResponse)
}))
defer server.Close()- testify: Assertions and mocking - https://github.com/stretchr/testify
- gomock: Mock generation - https://github.com/golang/mock
- Golden (snapshot) tests: Output formatters are covered by golden-file tests in
pkg/output/golden_test.go— uses real production structs frompkg/resources/*to capture exact output across all formats (table, wide, JSON, YAML, CSV, agent, watch, chart). Update withmake test-update-goldenorgo test ./pkg/output/ -run TestGolden -update.
Repository: https://github.com/cockroachdb/errors
Features:
- Error wrapping with context
- Stack traces
- Error formatting
- Compatible with standard
errorspackage - Better than pkg/errors (archived)
Usage:
if err != nil {
return errors.Wrap(err, "failed to fetch document")
}
// Exit codes
const (
ExitSuccess = 0
ExitError = 1
ExitUsageError = 2
ExitAuthError = 3
ExitNotFoundError = 4
ExitPermissionError = 5
)Repository: https://github.com/sirupsen/logrus
Features:
- Structured logging
- Multiple log levels
- Pluggable formatters
- Hooks for log routing
Repository: https://go.uber.org/zap
Features:
- Extremely fast
- Structured and leveled logging
- Zero-allocation in hot paths
Recommendation: Start with logrus, migrate to zap if performance becomes an issue
Repository: https://goreleaser.com/
Features:
- Cross-platform builds
- Archive creation (tar.gz, zip)
- Homebrew tap generation
- Docker image builds
- GitHub/GitLab release creation
- Checksums and signing
Configuration:
# .goreleaser.yml
builds:
- id: dtctl
binary: dtctl
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
env:
- CGO_ENABLED=0
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
brews:
- name: dtctl
repository:
owner: your-org
name: homebrew-tap
description: "Dynatrace platform CLI"
homepage: "https://github.com/your-org/dtctl"- Homebrew: Auto-generated via goreleaser
- apt/yum: DEB/RPM packages via goreleaser
- Scoop: Windows package manager
- Docker: Container image for CI/CD usage
Generation:
// Cobra provides built-in completion
rootCmd.CompletionCmd()Installation:
# bash
dtctl completion bash > /etc/bash_completion.d/dtctl
# zsh
dtctl completion zsh > "${fpath[1]}/_dtctl"
# fish
dtctl completion fish > ~/.config/fish/completions/dtctl.fishRepository: https://github.com/rhysd/go-github-selfupdate
Features:
- Check for new releases
- Download and replace binary
- Verify checksums
- GitHub Releases integration
Usage:
dtctl version --check-update
dtctl updatedtctl/
├── cmd/
│ ├── root.go # Root command
│ ├── get.go # Get command
│ ├── describe.go # Describe command
│ ├── create.go # Create command
│ ├── delete.go # Delete command
│ ├── apply.go # Apply command
│ ├── patch.go # Patch command
│ ├── edit.go # Edit command
│ ├── logs.go # Logs command
│ ├── exec.go # Exec command
│ ├── label.go # Label command
│ ├── wait.go # Wait command
│ ├── diff.go # Diff command
│ ├── explain.go # Explain command
│ ├── config.go # Config command
│ ├── auth.go # Auth command
│ ├── completion.go # Shell completion
│ └── version.go # Version command
│
├── pkg/
│ ├── api/ # Generated API clients
│ │ ├── document/ # Document API client
│ │ ├── slo/ # SLO API client
│ │ ├── automation/ # Automation API client
│ │ ├── grail/ # Grail API clients
│ │ ├── iam/ # IAM API client
│ │ └── ... # Other API clients
│ │
│ ├── client/ # Core API client logic
│ │ ├── client.go # Base client with auth, retry
│ │ ├── pagination.go # Pagination helpers
│ │ ├── rate_limit.go # Rate limiting
│ │ └── errors.go # Error handling
│ │
│ ├── config/ # Configuration management
│ │ ├── config.go # Config structure
│ │ ├── context.go # Context management
│ │ ├── loader.go # Config loading
│ │ └── writer.go # Config writing
│ │
│ ├── resources/ # Resource handlers
│ │ ├── document/ # Document resource
│ │ │ ├── get.go
│ │ │ ├── create.go
│ │ │ ├── update.go
│ │ │ ├── delete.go
│ │ │ └── share.go
│ │ ├── slo/ # SLO resource
│ │ ├── workflow/ # Workflow resource
│ │ └── ... # Other resources
│ │
│ ├── output/ # Output formatting
│ │ ├── table.go # Table formatter
│ │ ├── json.go # JSON formatter
│ │ ├── yaml.go # YAML formatter
│ │ ├── jsonpath.go # JSONPath formatter
│ │ ├── styles.go # Color control (ColorEnabled, Colorize, ColorCode)
│ │ ├── agent.go # Agent output envelope
│ │ └── printer.go # Printer interface
│ │
│ ├── manifest/ # Manifest parsing
│ │ ├── parser.go # YAML/JSON manifest parser
│ │ ├── validator.go # Manifest validation
│ │ └── applier.go # Apply logic (create/update)
│ │
│ ├── diff/ # Diff implementation
│ │ └── diff.go # Resource diffing logic
│ │
│ ├── watch/ # Watch implementation
│ │ └── watch.go # Resource watching
│ │
│ ├── exec/ # Exec implementations
│ │ ├── dql.go # DQL query executor
│ │ ├── workflow.go # Workflow executor
│ │ ├── slo.go # SLO evaluator
│ │ ├── function.go # Function executor
│ │ └── intent.go # Intent URL generator
│ │
│ └── util/ # Utilities
│ ├── editor.go # Interactive editor
│ ├── prompt.go # User prompts
│ ├── selector.go # Label selector parsing
│ └── version.go # Version comparison
│
├── scripts/ # Build and dev scripts
│ └── build.sh # Build script
│
├── test/ # Integration tests
│ ├── fixtures/ # Test fixtures
│ └── e2e/ # End-to-end tests
│
├── .goreleaser.yml # Release configuration
├── Makefile # Build automation
├── go.mod # Go module definition
├── go.sum # Dependency checksums
├── main.go # Entry point
├── README.md
├── API_DESIGN.md
├── ARCHITECTURE.md
└── AGENTS.md
Use Go modules (go.mod) for dependency management:
module github.com/yourorg/dtctl
go 1.24
require (
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.0
github.com/go-resty/resty/v2 v2.11.0
github.com/olekukonko/tablewriter v0.0.5
gopkg.in/yaml.v3 v3.0.1
github.com/cockroachdb/errors v1.11.1
github.com/sirupsen/logrus v1.9.3
github.com/go-playground/validator/v10 v10.16.0
github.com/stretchr/testify v1.8.4
k8s.io/client-go v0.29.0
).PHONY: all build clean test generate install
VERSION ?= $(shell git describe --tags --always --dirty)
COMMIT ?= $(shell git rev-parse --short HEAD)
DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS = -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) -s -w"
all: build
# Build the binary
build:
@echo "Building dtctl..."
@go build $(LDFLAGS) -o bin/dtctl .
# Run tests
test:
@echo "Running tests..."
@go test -v -race -coverprofile=coverage.out ./...
# Install locally
install:
@echo "Installing dtctl..."
@go install $(LDFLAGS) .
# Clean build artifacts
clean:
@rm -rf bin/ dist/ coverage.out
# Run linter
lint:
@golangci-lint run
# Format code
fmt:
@go fmt ./...
@goimports -w .
# Release (using goreleaser)
release:
@goreleaser release --clean
# Release snapshot (local testing)
release-snapshot:
@goreleaser release --snapshot --clean# Clone repository
git clone https://github.com/yourorg/dtctl.git
cd dtctl
# Install dependencies
go mod download
# Build
make build
# Run tests
make test# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Install dependencies
run: go mod download
- name: Run tests
run: make test
- name: Run linter
run: make lint
- name: Build
run: make build
release:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}// cmd/get.go
package cmd
import (
"github.com/spf13/cobra"
"github.com/yourorg/dtctl/pkg/resources"
)
var getCmd = &cobra.Command{
Use: "get [resource]",
Short: "Display one or many resources",
}
var getDocumentsCmd = &cobra.Command{
Use: "documents [id]",
Aliases: []string{"document", "doc"},
Short: "Get documents",
RunE: runGetDocuments,
}
func runGetDocuments(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
client := client.NewFromConfig(cfg)
handler := resources.NewDocumentHandler(client)
opts := resources.GetOptions{
Output: cmd.Flags().GetString("output"),
Selector: cmd.Flags().GetString("selector"),
}
if len(args) > 0 {
return handler.Get(args[0], opts)
}
return handler.List(opts)
}
func init() {
getCmd.AddCommand(getDocumentsCmd)
rootCmd.AddCommand(getCmd)
// Add flags
getDocumentsCmd.Flags().StringP("output", "o", "table", "Output format")
getDocumentsCmd.Flags().StringP("selector", "l", "", "Label selector")
}// pkg/resources/document/handler.go
package document
import (
"context"
"github.com/yourorg/dtctl/pkg/api/document"
"github.com/yourorg/dtctl/pkg/output"
)
type Handler struct {
client *document.Client
}
func NewHandler(client *document.Client) *Handler {
return &Handler{client: client}
}
func (h *Handler) Get(id string, opts GetOptions) error {
ctx := context.Background()
doc, err := h.client.GetDocument(ctx, id)
if err != nil {
return errors.Wrap(err, "failed to get document")
}
printer := output.NewPrinter(opts.Output)
return printer.Print(doc)
}
func (h *Handler) List(opts ListOptions) error {
ctx := context.Background()
params := &document.ListDocumentsParams{
Owner: opts.Owner,
Type: opts.Type,
}
docs, err := h.client.ListDocuments(ctx, params)
if err != nil {
return errors.Wrap(err, "failed to list documents")
}
printer := output.NewPrinter(opts.Output)
return printer.PrintList(docs)
}// pkg/output/printer.go
package output
import (
"encoding/json"
"io"
)
type Printer interface {
Print(interface{}) error
PrintList(interface{}) error
}
func NewPrinter(format string, writer io.Writer) Printer {
switch format {
case "json":
return &JSONPrinter{writer: writer}
case "yaml":
return &YAMLPrinter{writer: writer}
case "table", "wide":
return &TablePrinter{writer: writer, wide: format == "wide"}
default:
return &TablePrinter{writer: writer}
}
}
type JSONPrinter struct {
writer io.Writer
}
func (p *JSONPrinter) Print(obj interface{}) error {
encoder := json.NewEncoder(p.writer)
encoder.SetIndent("", " ")
return encoder.Encode(obj)
}// pkg/client/client.go
package client
import (
"github.com/go-resty/resty/v2"
"github.com/yourorg/dtctl/pkg/config"
)
type Client struct {
http *resty.Client
baseURL string
token string
}
func NewFromConfig(cfg *config.Config) (*Client, error) {
ctx := cfg.CurrentContext()
if ctx == nil {
return nil, errors.New("no current context")
}
token := cfg.GetToken(ctx.TokenRef)
if token == "" {
return nil, errors.New("no token found")
}
httpClient := resty.New().
SetBaseURL(ctx.Environment).
SetAuthToken(token).
SetRetryCount(3).
AddRetryCondition(isRetryable).
SetLogger(log.StandardLogger())
return &Client{
http: httpClient,
baseURL: ctx.Environment,
token: token,
}, nil
}
func isRetryable(r *resty.Response, err error) bool {
if err != nil {
return true
}
// Retry on rate limit or server errors
return r.StatusCode() == 429 || r.StatusCode() >= 500
}Use goroutines for independent operations:
// Fetch multiple resources in parallel
var wg sync.WaitGroup
results := make(chan *Document, len(ids))
errors := make(chan error, len(ids))
for _, id := range ids {
wg.Add(1)
go func(id string) {
defer wg.Done()
doc, err := client.GetDocument(ctx, id)
if err != nil {
errors <- err
return
}
results <- doc
}(id)
}
wg.Wait()
close(results)
close(errors)Implement caching for schemas and templates:
type Cache struct {
schemas map[string]*Schema
ttl time.Duration
mu sync.RWMutex
}
func (c *Cache) GetSchema(id string) (*Schema, error) {
c.mu.RLock()
schema, ok := c.schemas[id]
c.mu.RUnlock()
if ok {
return schema, nil
}
// Fetch and cache
schema, err := fetchSchema(id)
if err != nil {
return nil, err
}
c.mu.Lock()
c.schemas[id] = schema
c.mu.Unlock()
return schema, nil
}Stream large result sets:
func (h *Handler) StreamLogs(id string, follow bool) error {
stream, err := h.client.StreamLogs(ctx, id)
if err != nil {
return err
}
defer stream.Close()
scanner := bufio.NewScanner(stream)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
return scanner.Err()
}- Store tokens in config file with restricted permissions (0600)
- Consider integration with OS keychain/credential manager
- Support for credential helpers (similar to Docker)
httpClient.SetTLSClientConfig(&tls.Config{
MinVersion: tls.VersionTLS12,
// Add custom CA if needed
})- Validate all user inputs
- Sanitize file paths
- Validate resource IDs and names
- Plugin System: Support for extending dtctl with plugins (using Go plugins or exec-based)
- Interactive Mode: TUI using bubbletea/lipgloss
- Local Development Mode: Mock server for testing
- Credential Providers: Integration with HashiCorp Vault, AWS Secrets Manager
- Advanced Caching: Persistent cache with TTL and invalidation
- Metrics: Built-in metrics collection for usage analytics
- Auto-update: Automatic version checking and updates
This architecture provides:
✅ Production-ready: Based on proven technologies (kubectl stack) ✅ Maintainable: Clear project structure and patterns ✅ Performant: Fast startup, efficient resource usage ✅ Cross-platform: Single binary for all major platforms ✅ Extensible: Easy to add new resources and commands ✅ Type-safe: Generated clients from OpenAPI specs ✅ Well-tested: Comprehensive testing strategy ✅ Professional: Modern CI/CD and release automation
The proposed stack minimizes external dependencies while leveraging battle-tested libraries from the Kubernetes ecosystem and Go community.