CLI, Configuration, Secrets, dan Operational Interface
Materi mendalam Go untuk CLI, configuration, secrets, startup validation, operational command, migration command, dan runtime interface service production-grade.
CLI, Configuration, Secrets, dan Operational Interface
Service production tidak hanya terdiri dari handler, database, dan business logic. Service juga harus bisa dijalankan, dikonfigurasi, diamati, dimigrasikan, dan dihentikan dengan aman.
Di sinilah operational interface menjadi penting.
Operational interface adalah semua cara manusia, CI/CD, platform, dan automation berinteraksi dengan binary kita:
- command line flags;
- environment variables;
- config file;
- secret source;
- migration command;
- health command;
- version command;
- startup validation;
- exit code;
- logs saat boot;
- graceful shutdown behavior.
Go sangat kuat di area ini karena menghasilkan binary yang sederhana didistribusikan, punya standard library yang cukup untuk CLI dasar, dan cocok untuk service maupun command-line tooling.
Target Pembelajaran
Setelah menyelesaikan part ini, kita harus mampu:
- Mendesain CLI kecil untuk service Go tanpa over-engineering.
- Membedakan config, secret, dan runtime state.
- Membuat precedence rule configuration yang eksplisit.
- Melakukan startup validation sebelum server menerima traffic.
- Membuat command
serve,migrate,version, dancheck-config. - Menangani secret tanpa membocorkannya ke log atau error response.
- Mendesain exit code yang berguna untuk automation.
- Membuat binary Go yang predictable untuk container dan CI/CD.
Hubungan dengan Framework Kaufman
Dalam kerangka Kaufman, part ini sangat terkait dengan:
- Remove barriers to practice: environment yang konsisten membuat latihan dan deployment tidak rapuh.
- Learn enough to self-correct: kita belajar mendeteksi config error saat startup, bukan setelah incident.
- Deliberate practice: kita membangun operational command nyata.
Banyak engineer belajar bahasa hanya sampai bisa menulis feature. Engineer production-grade harus memastikan feature bisa dijalankan secara aman di lingkungan berbeda.
Mental Model: Binary adalah Produk Operasional
Saat kita menjalankan:
case-api serve --config ./config/local.yaml
binary tidak hanya menjalankan kode. Binary membuat kontrak dengan operator:
- argumen apa yang diterima;
- environment variable apa yang dibutuhkan;
- secret apa yang harus tersedia;
- validasi apa yang dilakukan;
- log startup apa yang muncul;
- kapan exit dengan code non-zero;
- bagaimana proses berhenti saat menerima signal.
Rule besar:
Jangan biarkan service mulai menerima traffic sebelum configuration, dependency, dan runtime assumption tervalidasi.
Config vs Secret vs State
Sebelum menulis kode, bedakan tiga hal ini.
Config
Config adalah nilai yang mengubah behavior deploy tanpa mengubah kode.
Contoh:
- HTTP port;
- database host;
- log level;
- timeout;
- feature flag;
- max request body size;
- pagination limit;
- environment name.
Secret
Secret adalah nilai sensitif yang memberi akses atau otorisasi.
Contoh:
- database password;
- API key;
- private key;
- signing key;
- OAuth client secret;
- token.
State
State adalah data runtime yang berubah karena aplikasi berjalan.
Contoh:
- cache content;
- session;
- database row;
- idempotency record;
- queue offset.
Jangan perlakukan state sebagai config. Jangan log secret sebagai config. Jangan compile secret ke binary.
Sumber Configuration
Sumber config umum:
- Default value.
- Config file.
- Environment variable.
- CLI flag.
- Secret manager.
Precedence harus jelas.
Contoh yang pragmatis:
CLI flag > environment variable > config file > default
Kenapa CLI flag paling tinggi?
Karena operator yang menjalankan command secara eksplisit biasanya ingin override cepat.
Namun untuk service container, banyak organisasi memilih environment variable sebagai interface utama. Itu juga valid. Yang penting adalah konsisten dan terdokumentasi.
Struktur Command
Kita akan membuat binary case-api dengan command:
case-api serve
case-api migrate
case-api check-config
case-api version
Makna:
| Command | Tujuan |
|---|---|
serve | Menjalankan HTTP server |
migrate | Menjalankan database migration |
check-config | Memvalidasi config tanpa menjalankan server |
version | Mencetak version/build info |
Untuk project kecil, kita bisa memakai standard library flag.
Untuk CLI yang lebih besar, library seperti Cobra sering dipakai. Tetapi penting untuk memahami bentuk dasarnya dulu tanpa framework.
Main yang Tipis
main sebaiknya tipis.
package main
import (
"context"
"fmt"
"os"
"example.com/caseapi/internal/cli"
)
func main() {
ctx := context.Background()
if err := cli.Run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(cli.ExitCode(err))
}
}
Kenapa Run menerima args, stdout, dan stderr?
Agar bisa dites tanpa menjalankan proses sungguhan.
func TestVersionCommand(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
err := cli.Run(context.Background(), []string{"version"}, &out, &errOut)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if !strings.Contains(out.String(), "case-api") {
t.Fatalf("version output = %q", out.String())
}
}
Rule:
mainwiring process-level concerns. Logic command hidup di package yang bisa dites.
Exit Code
Exit code adalah API untuk shell, CI/CD, supervisor, dan Kubernetes job.
Minimal:
| Code | Makna |
|---|---|
0 | Success |
1 | General error |
2 | Usage/config error |
3 | Dependency unavailable |
4 | Migration failed |
Implementasi:
package cli
type ExitError struct {
Code int
Err error
}
func (e ExitError) Error() string {
return e.Err.Error()
}
func (e ExitError) Unwrap() error {
return e.Err
}
func ExitCode(err error) int {
if err == nil {
return 0
}
var ee ExitError
if errors.As(err, &ee) {
return ee.Code
}
return 1
}
Usage error:
return ExitError{Code: 2, Err: fmt.Errorf("unknown command %q", cmd)}
Jangan semua error keluar sebagai 1 jika automation perlu membedakan failure class.
Config Struct
Gunakan struct eksplisit.
package config
import "time"
type Config struct {
Env string
HTTP HTTPConfig
Database DatabaseConfig
Logging LoggingConfig
Security SecurityConfig
}
type HTTPConfig struct {
Addr string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxBodyBytes int64
}
type DatabaseConfig struct {
URL string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
type LoggingConfig struct {
Level string
JSON bool
}
type SecurityConfig struct {
HMACSigningKey string
}
Catatan:
- Secret masih berupa field agar aplikasi bisa memakai nilainya.
- Tetapi field secret tidak boleh dicetak mentah.
- Bisa juga memakai custom type untuk secret agar redaction lebih aman.
Redacted Config
Untuk startup log, kita ingin tahu config apa yang dipakai tanpa membocorkan secret.
type RedactedConfig struct {
Env string
HTTP HTTPConfig
Database RedactedDatabaseConfig
Logging LoggingConfig
Security RedactedSecurityConfig
}
type RedactedDatabaseConfig struct {
URL string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
type RedactedSecurityConfig struct {
HMACSigningKey string
}
func (c Config) Redacted() RedactedConfig {
return RedactedConfig{
Env: c.Env,
HTTP: c.HTTP,
Database: RedactedDatabaseConfig{
URL: redactDatabaseURL(c.Database.URL),
MaxOpenConns: c.Database.MaxOpenConns,
MaxIdleConns: c.Database.MaxIdleConns,
ConnMaxLifetime: c.Database.ConnMaxLifetime,
},
Logging: c.Logging,
Security: RedactedSecurityConfig{
HMACSigningKey: redactSecret(c.Security.HMACSigningKey),
},
}
}
func redactSecret(s string) string {
if s == "" {
return ""
}
return "<redacted>"
}
Database URL perlu redaction khusus karena password sering berada di URL:
postgres://user:password@host:5432/db
Jangan log URL mentah.
Default Config
Default harus aman untuk local development, bukan production.
func Default() Config {
return Config{
Env: "local",
HTTP: HTTPConfig{
Addr: ":8080",
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxBodyBytes: 1 << 20,
},
Database: DatabaseConfig{
MaxOpenConns: 10,
MaxIdleConns: 5,
ConnMaxLifetime: 30 * time.Minute,
},
Logging: LoggingConfig{
Level: "info",
JSON: true,
},
}
}
Hati-hati:
- Jangan set default production secret.
- Jangan default ke insecure mode untuk production.
- Jangan default timeout ke nol jika nol berarti no timeout.
Environment Variable Loader
Sederhana dulu:
func ApplyEnv(c *Config, lookup func(string) (string, bool)) error {
if v, ok := lookup("CASE_ENV"); ok {
c.Env = v
}
if v, ok := lookup("CASE_HTTP_ADDR"); ok {
c.HTTP.Addr = v
}
if v, ok := lookup("CASE_DATABASE_URL"); ok {
c.Database.URL = v
}
if v, ok := lookup("CASE_LOG_LEVEL"); ok {
c.Logging.Level = v
}
if v, ok := lookup("CASE_HMAC_SIGNING_KEY"); ok {
c.Security.HMACSigningKey = v
}
if v, ok := lookup("CASE_HTTP_MAX_BODY_BYTES"); ok {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("CASE_HTTP_MAX_BODY_BYTES must be integer: %w", err)
}
c.HTTP.MaxBodyBytes = n
}
return nil
}
Kenapa menerima lookup func(string) (string, bool)?
Agar test mudah:
func TestApplyEnv(t *testing.T) {
cfg := config.Default()
env := map[string]string{
"CASE_HTTP_ADDR": ":9090",
}
err := config.ApplyEnv(&cfg, func(k string) (string, bool) {
v, ok := env[k]
return v, ok
})
if err != nil {
t.Fatal(err)
}
if cfg.HTTP.Addr != ":9090" {
t.Fatalf("addr = %q", cfg.HTTP.Addr)
}
}
Config File
Config file berguna untuk local development dan environment yang punya banyak parameter.
Contoh YAML:
# config/local.yaml
env: local
http:
addr: ":8080"
read_header_timeout: "5s"
read_timeout: "10s"
write_timeout: "30s"
idle_timeout: "60s"
max_body_bytes: 1048576
database:
url: "postgres://case:case@localhost:5432/case?sslmode=disable"
max_open_conns: 10
max_idle_conns: 5
conn_max_lifetime: "30m"
logging:
level: "debug"
json: false
Go standard library tidak punya YAML parser. Untuk production, kita bisa:
- memakai JSON/TOML jika ingin dependency minimal;
- memakai YAML library dengan dependency hygiene;
- memakai environment-only config.
Contoh JSON config agar standard-library-only:
{
"env": "local",
"http": {
"addr": ":8080",
"read_header_timeout": "5s",
"read_timeout": "10s",
"write_timeout": "30s",
"idle_timeout": "60s",
"max_body_bytes": 1048576
}
}
Karena time.Duration default JSON adalah number nanoseconds, sering lebih manusiawi memakai DTO config file dengan string duration lalu mapping.
type fileConfig struct {
Env string `json:"env"`
HTTP struct {
Addr string `json:"addr"`
ReadHeaderTimeout string `json:"read_header_timeout"`
ReadTimeout string `json:"read_timeout"`
WriteTimeout string `json:"write_timeout"`
IdleTimeout string `json:"idle_timeout"`
MaxBodyBytes int64 `json:"max_body_bytes"`
} `json:"http"`
}
Mapping duration:
func parseDurationField(name, raw string) (time.Duration, error) {
if raw == "" {
return 0, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("%s must be duration: %w", name, err)
}
return d, nil
}
CLI Flags
Untuk serve:
func runServe(ctx context.Context, args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(stderr)
var configPath string
var httpAddr string
fs.StringVar(&configPath, "config", "", "path to config file")
fs.StringVar(&httpAddr, "http-addr", "", "HTTP listen address")
if err := fs.Parse(args); err != nil {
return ExitError{Code: 2, Err: err}
}
cfg := config.Default()
if configPath != "" {
if err := config.ApplyFile(&cfg, configPath); err != nil {
return ExitError{Code: 2, Err: err}
}
}
if err := config.ApplyEnv(&cfg, os.LookupEnv); err != nil {
return ExitError{Code: 2, Err: err}
}
if httpAddr != "" {
cfg.HTTP.Addr = httpAddr
}
if err := cfg.Validate(); err != nil {
return ExitError{Code: 2, Err: err}
}
return serve(ctx, cfg, stdout, stderr)
}
Perhatikan precedence:
- default;
- config file;
- env;
- flag.
Ini eksplisit dalam kode.
Startup Validation
Startup validation harus menangkap config invalid sebelum service menerima traffic.
func (c Config) Validate() error {
var errs []error
if c.Env == "" {
errs = append(errs, errors.New("env is required"))
}
switch c.Env {
case "local", "dev", "staging", "prod":
default:
errs = append(errs, fmt.Errorf("env must be one of local, dev, staging, prod"))
}
if c.HTTP.Addr == "" {
errs = append(errs, errors.New("http.addr is required"))
}
if c.HTTP.ReadHeaderTimeout <= 0 {
errs = append(errs, errors.New("http.read_header_timeout must be > 0"))
}
if c.HTTP.MaxBodyBytes <= 0 {
errs = append(errs, errors.New("http.max_body_bytes must be > 0"))
}
if c.Database.URL == "" {
errs = append(errs, errors.New("database.url is required"))
}
if c.Database.MaxOpenConns <= 0 {
errs = append(errs, errors.New("database.max_open_conns must be > 0"))
}
if c.Env == "prod" && c.Security.HMACSigningKey == "" {
errs = append(errs, errors.New("security.hmac_signing_key is required in prod"))
}
return errors.Join(errs...)
}
errors.Join membuat kita bisa mengembalikan beberapa error sekaligus. Ini sangat berguna untuk config validation karena operator ingin melihat semua masalah, bukan memperbaiki satu per satu.
Output buruk:
invalid config
Output lebih baik:
config validation failed:
- database.url is required
- http.read_header_timeout must be > 0
- security.hmac_signing_key is required in prod
Kita bisa membuat formatter khusus untuk error join jika diperlukan.
Secret Handling
Secret harus diperlakukan sebagai nilai berbahaya.
Rule praktis:
- Jangan commit secret.
- Jangan hardcode secret di source code.
- Jangan print secret di startup log.
- Jangan return secret di error.
- Jangan expose secret di
/debugendpoint. - Jangan menyimpan secret di metric label.
- Jangan passing secret melalui command line di sistem multi-user jika process list bisa dibaca user lain.
- Prefer env var atau secret manager untuk containerized service.
- Rotasi secret harus dipikirkan jika service long-running.
Secret Type
Kita bisa membuat type kecil:
type Secret string
func (s Secret) String() string {
if s == "" {
return ""
}
return "<redacted>"
}
func (s Secret) Value() string {
return string(s)
}
Lalu config:
type SecurityConfig struct {
HMACSigningKey Secret
}
Ini mengurangi risiko accidental logging:
fmt.Println(cfg.Security.HMACSigningKey) // <redacted>
Namun tetap hati-hati: conversion ke string mentah masih bisa terjadi melalui Value().
Version Command
Version command membantu debugging production.
case-api version
Output:
case-api version=1.4.2 commit=abc123 built_at=2026-06-27T10:00:00Z go=go1.26.0
Implementasi:
package buildinfo
import "runtime"
var (
Version = "dev"
Commit = "unknown"
BuiltAt = "unknown"
)
type Info struct {
Version string
Commit string
BuiltAt string
GoVersion string
}
func Get() Info {
return Info{
Version: Version,
Commit: Commit,
BuiltAt: BuiltAt,
GoVersion: runtime.Version(),
}
}
Inject saat build:
go build \
-ldflags "-X example.com/caseapi/internal/buildinfo.Version=1.4.2 \
-X example.com/caseapi/internal/buildinfo.Commit=$(git rev-parse --short HEAD) \
-X example.com/caseapi/internal/buildinfo.BuiltAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
./cmd/api
Command:
func runVersion(stdout io.Writer) error {
info := buildinfo.Get()
fmt.Fprintf(stdout, "case-api version=%s commit=%s built_at=%s go=%s\n",
info.Version,
info.Commit,
info.BuiltAt,
info.GoVersion,
)
return nil
}
Check Config Command
check-config berguna di CI/CD dan sebelum deploy.
case-api check-config --config ./config/prod.json
Behavior:
- load config;
- apply env;
- validate;
- print redacted config;
- exit
0jika valid; - exit
2jika invalid.
func runCheckConfig(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("check-config", flag.ContinueOnError)
fs.SetOutput(stderr)
var configPath string
fs.StringVar(&configPath, "config", "", "path to config file")
if err := fs.Parse(args); err != nil {
return ExitError{Code: 2, Err: err}
}
cfg := config.Default()
if configPath != "" {
if err := config.ApplyFile(&cfg, configPath); err != nil {
return ExitError{Code: 2, Err: err}
}
}
if err := config.ApplyEnv(&cfg, os.LookupEnv); err != nil {
return ExitError{Code: 2, Err: err}
}
if err := cfg.Validate(); err != nil {
return ExitError{Code: 2, Err: fmt.Errorf("config validation failed: %w", err)}
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(cfg.Redacted())
}
Ini adalah contoh command kecil yang sangat berguna secara operasional.
Migration Command
Migration sering dipisah dari serve.
case-api migrate --config ./config/prod.json
Kenapa tidak auto-migrate saat server start?
Auto-migration saat startup bisa berbahaya jika:
- banyak replica start bersamaan;
- migration butuh lock panjang;
- migration irreversible;
- schema change butuh koordinasi dengan deploy bertahap;
- service mulai menerima traffic saat migration belum aman.
Command migration memberi kontrol lebih eksplisit.
Pola:
func runMigrate(ctx context.Context, args []string, stdout, stderr io.Writer) error {
cfg, err := loadConfigForCommand(args, stderr)
if err != nil {
return ExitError{Code: 2, Err: err}
}
db, err := sql.Open("postgres", cfg.Database.URL)
if err != nil {
return ExitError{Code: 3, Err: fmt.Errorf("open database: %w", err)}
}
defer db.Close()
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return ExitError{Code: 3, Err: fmt.Errorf("ping database: %w", err)}
}
if err := migrate.Up(ctx, db); err != nil {
return ExitError{Code: 4, Err: fmt.Errorf("migration failed: %w", err)}
}
fmt.Fprintln(stdout, "migration completed")
return nil
}
Migration harus:
- context-aware;
- punya timeout;
- punya log jelas;
- idempotent jika memungkinkan;
- aman untuk dijalankan ulang;
- tidak menyembunyikan partial failure.
Serve Command dan Wiring
serve menggabungkan config, dependency, server, dan shutdown.
func serve(ctx context.Context, cfg config.Config, stdout, stderr io.Writer) error {
logger := newLogger(cfg.Logging, stderr)
logger.Info("starting case-api", "config", cfg.Redacted())
db, err := openDatabase(ctx, cfg.Database)
if err != nil {
return ExitError{Code: 3, Err: err}
}
defer db.Close()
repo := store.NewPostgres(db)
svc := caseapp.NewService(repo, realIDGenerator{}, realClock{})
api := httpapi.NewServer(svc, logger, cfg.HTTP.MaxBodyBytes)
httpServer := &http.Server{
Addr: cfg.HTTP.Addr,
Handler: api.Routes(),
ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout,
ReadTimeout: cfg.HTTP.ReadTimeout,
WriteTimeout: cfg.HTTP.WriteTimeout,
IdleTimeout: cfg.HTTP.IdleTimeout,
}
errCh := make(chan error, 1)
go func() {
logger.Info("http server listening", "addr", cfg.HTTP.Addr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
logger.Info("shutdown requested")
case err := <-errCh:
if err != nil {
return fmt.Errorf("http server failed: %w", err)
}
return nil
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown failed: %w", err)
}
logger.Info("shutdown complete")
return nil
}
Signal handling biasanya di Run atau main layer:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
Jika serve menerima context, test dan caller punya kontrol lifecycle.
Logging Saat Startup
Startup log harus menjawab:
- binary version apa?
- environment apa?
- listen address apa?
- config apa yang dipakai?
- dependency apa yang berhasil terkoneksi?
- apakah migration version cocok?
Namun jangan terlalu bising.
Contoh:
level=INFO msg="starting case-api" version=1.4.2 commit=abc123 env=prod
level=INFO msg="loaded config" http.addr=:8080 database.url="postgres://case:<redacted>@db:5432/case"
level=INFO msg="database connected" max_open_conns=50
level=INFO msg="http server listening" addr=:8080
Hindari:
DATABASE_URL=postgres://case:secret@db:5432/case
HMAC_SIGNING_KEY=supersecret
Operational Config Checklist
Gunakan checklist ini saat review service Go:
- Apakah semua config punya owner dan dokumentasi?
- Apakah precedence rule jelas?
- Apakah config invalid gagal saat startup?
- Apakah timeout default aman?
- Apakah secret tidak pernah dicetak?
- Apakah production wajib mengisi secret?
- Apakah binary punya
versioncommand? - Apakah ada
check-configcommand? - Apakah migration tidak otomatis berbahaya?
- Apakah exit code berguna untuk CI/CD?
- Apakah command bisa dites tanpa menjalankan process asli?
- Apakah graceful shutdown memakai context dan timeout?
- Apakah startup log cukup untuk debugging deploy?
- Apakah config type tidak terlalu generic seperti
map[string]any? - Apakah environment variable naming konsisten?
Common Anti-pattern
Anti-pattern 1: Global Config Mutable
var Config config.Config
Masalah:
- test saling mempengaruhi;
- dependency tersembunyi;
- race risk;
- susah menjalankan beberapa instance dalam satu process.
Lebih baik pass config sebagai dependency saat wiring.
Anti-pattern 2: Membaca Env di Mana-mana
func NewDatabase() *sql.DB {
url := os.Getenv("DATABASE_URL")
...
}
Masalah:
- dependency tidak eksplisit;
- test sulit;
- precedence tidak jelas;
- validation tersebar.
Lebih baik semua env dibaca di config loader.
Anti-pattern 3: Timeout Nol
Banyak zero value Go berguna, tetapi timeout nol sering berarti no timeout. Untuk network service, ini berbahaya.
Pastikan config validation menolak timeout yang tidak sengaja nol.
Anti-pattern 4: Secret di Flag
case-api serve --db-password supersecret
Di banyak sistem, command line process bisa terlihat melalui process list atau audit log. Gunakan env var atau secret manager.
Anti-pattern 5: panic untuk Config Error Biasa
Untuk config invalid, return error dengan exit code jelas. panic cocok untuk programmer bug yang tidak seharusnya terjadi, bukan input operator yang salah.
Testing CLI dan Config
Test config validation:
func TestConfigValidateRequiresDatabaseURL(t *testing.T) {
cfg := config.Default()
cfg.Database.URL = ""
err := cfg.Validate()
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "database.url") {
t.Fatalf("error = %v, want database.url", err)
}
}
Test command unknown:
func TestUnknownCommand(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
err := cli.Run(context.Background(), []string{"wat"}, &out, &errOut)
if err == nil {
t.Fatal("expected error")
}
if code := cli.ExitCode(err); code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
}
Test check-config redaction:
func TestCheckConfigRedactsSecret(t *testing.T) {
t.Setenv("CASE_DATABASE_URL", "postgres://user:pass@localhost:5432/db")
t.Setenv("CASE_HMAC_SIGNING_KEY", "secret")
var out bytes.Buffer
var errOut bytes.Buffer
err := cli.Run(context.Background(), []string{"check-config"}, &out, &errOut)
if err != nil {
t.Fatal(err)
}
got := out.String()
if strings.Contains(got, "secret") || strings.Contains(got, "pass") {
t.Fatalf("output leaked secret: %s", got)
}
}
Testing operational interface sering terasa membosankan, tetapi sangat efektif mencegah incident konfigurasi.
Container Implication
Dalam container, binary Go sering menjadi process utama.
Dockerfile sederhana:
FROM gcr.io/distroless/base-debian12
COPY case-api /case-api
USER nonroot:nonroot
ENTRYPOINT ["/case-api"]
CMD ["serve"]
Karena command default serve, operator masih bisa override:
docker run case-api version
docker run case-api check-config
docker run case-api migrate
Pastikan:
- binary menangani SIGTERM;
- logs ke stdout/stderr;
- config via env/file jelas;
- tidak butuh shell jika image distroless;
- command
versiontetap bisa dijalankan.
Latihan 20 Jam: Slot untuk Operational Interface
Gunakan 2 jam untuk part ini:
| Waktu | Latihan |
|---|---|
| 15 menit | Buat main tipis yang memanggil cli.Run |
| 15 menit | Tambahkan command version |
| 20 menit | Buat Config struct dan default |
| 20 menit | Buat env loader yang bisa dites |
| 20 menit | Buat validation dengan beberapa error |
| 15 menit | Buat redaction untuk secret |
| 20 menit | Buat check-config command |
| 20 menit | Buat skeleton serve dengan signal context |
| 20 menit | Tulis test untuk unknown command, config invalid, redaction |
| 15 menit | Review operational checklist |
Output akhir:
- binary punya command
serve,version,check-config; - config loader punya precedence jelas;
- secret tidak bocor di output;
- config invalid gagal sebelum service start;
- command bisa dites.
Kesimpulan
CLI, config, secrets, dan operational interface sering dianggap bagian sekunder. Dalam production engineering, ini bagian utama.
Service yang fiturnya benar tetapi sulit dikonfigurasi tetap berisiko. Service yang gagal saat startup tanpa pesan jelas menyulitkan deploy. Service yang mencetak secret ke log bisa menjadi incident security. Service yang tidak punya version command membuat debugging release lebih lambat.
Go cocok untuk membangun operational interface yang sederhana dan kuat karena:
- binary mudah didistribusikan;
- standard library cukup untuk CLI dasar;
- config dapat dimodelkan dengan struct eksplisit;
- error handling mendorong startup validation jelas;
- testing command bisa dilakukan tanpa process nyata.
Prinsip akhirnya:
Treat operational behavior as part of your API.
Di part berikutnya, kita akan masuk ke logging, metrics, tracing, dan observability. Setelah service bisa dikonfigurasi dan dijalankan dengan benar, kita perlu memastikan ia bisa dipahami saat berjalan di production.
You just completed lesson 22 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.