Skip to content

Commit 9dc66e1

Browse files
author
Test
committed
feat: add ANCC-compliant CLI with Cobra subcommands
1 parent 4b801a5 commit 9dc66e1

12 files changed

Lines changed: 850 additions & 29 deletions

File tree

cmd/deployscope/main.go

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,12 @@
11
package main
22

33
import (
4-
"log"
5-
"net/http"
6-
"os"
7-
8-
"github.com/ppiankov/deployscope/internal/k8s"
9-
"github.com/ppiankov/deployscope/internal/metrics"
10-
"github.com/ppiankov/deployscope/internal/server"
4+
"github.com/ppiankov/deployscope/internal/cli"
115
)
126

137
var version = "dev"
148

159
func main() {
16-
port := os.Getenv("PORT")
17-
if port == "" {
18-
port = "8080"
19-
}
20-
21-
k8sClient, err := k8s.NewClient()
22-
if err != nil {
23-
log.Fatalf("failed to create kubernetes client: %v", err)
24-
}
25-
26-
corsOrigin := os.Getenv("CORS_ORIGIN")
27-
srv := server.New(k8sClient, corsOrigin)
28-
29-
mux := http.NewServeMux()
30-
srv.RegisterRoutes(mux)
31-
32-
handler := metrics.Middleware(mux)
33-
34-
log.Printf("deployscope v%s starting on port %s", version, port)
35-
log.Fatal(http.ListenAndServe(":"+port, handler))
10+
cli.SetVersion(version)
11+
cli.Execute()
3612
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.23.0
44

55
require (
66
github.com/prometheus/client_golang v1.23.2
7+
github.com/spf13/cobra v1.10.2
78
k8s.io/api v0.28.4
89
k8s.io/apimachinery v0.28.4
910
k8s.io/client-go v0.28.4
@@ -25,6 +26,7 @@ require (
2526
github.com/google/go-cmp v0.7.0 // indirect
2627
github.com/google/gofuzz v1.2.0 // indirect
2728
github.com/google/uuid v1.3.0 // indirect
29+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2830
github.com/josharian/intern v1.0.0 // indirect
2931
github.com/json-iterator/go v1.1.12 // indirect
3032
github.com/mailru/easyjson v0.7.7 // indirect
@@ -35,6 +37,7 @@ require (
3537
github.com/prometheus/client_model v0.6.2 // indirect
3638
github.com/prometheus/common v0.66.1 // indirect
3739
github.com/prometheus/procfs v0.16.1 // indirect
40+
github.com/spf13/pflag v1.0.9 // indirect
3841
go.yaml.in/yaml/v2 v2.4.2 // indirect
3942
golang.org/x/net v0.43.0 // indirect
4043
golang.org/x/oauth2 v0.30.0 // indirect

go.sum

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
22
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
33
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
44
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
5+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
56
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
67
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
78
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -38,6 +39,8 @@ github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJY
3839
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
3940
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
4041
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
42+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
43+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4144
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
4245
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
4346
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -82,8 +85,11 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
8285
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
8386
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
8487
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
85-
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
86-
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
88+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
89+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
90+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
91+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
92+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
8793
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
8894
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
8995
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -99,6 +105,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
99105
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
100106
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
101107
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
108+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
102109
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
103110
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
104111
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=

internal/cli/doctor.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/ppiankov/deployscope/internal/k8s"
11+
)
12+
13+
type DoctorOutput struct {
14+
K8sConnectivity string `json:"k8s_connectivity"`
15+
TotalWorkloads int `json:"total_workloads"`
16+
IgnoredWorkloads int `json:"ignored_workloads"`
17+
Coverage AnnotationCoverage `json:"annotation_coverage"`
18+
AgentReadiness float64 `json:"agent_readiness"`
19+
Warnings []string `json:"warnings,omitempty"`
20+
}
21+
22+
type AnnotationCoverage struct {
23+
Owner float64 `json:"owner"`
24+
Tier float64 `json:"tier"`
25+
GitOpsRepo float64 `json:"gitops_repo"`
26+
Oncall float64 `json:"oncall"`
27+
Runbook float64 `json:"runbook"`
28+
DependsOn float64 `json:"depends_on"`
29+
}
30+
31+
func newDoctorCmd() *cobra.Command {
32+
var format string
33+
34+
cmd := &cobra.Command{
35+
Use: "doctor",
36+
Short: "Check K8s connectivity, RBAC, and annotation coverage",
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
k8sClient, err := k8s.NewClient()
39+
if err != nil {
40+
output := DoctorOutput{
41+
K8sConnectivity: fmt.Sprintf("error: %v", err),
42+
}
43+
if format == "json" {
44+
enc := json.NewEncoder(os.Stdout)
45+
enc.SetIndent("", " ")
46+
_ = enc.Encode(output)
47+
} else {
48+
fmt.Printf("K8s connectivity: FAIL (%v)\n", err)
49+
}
50+
os.Exit(1)
51+
return nil
52+
}
53+
54+
if err := k8sClient.CheckReady(cmd.Context()); err != nil {
55+
output := DoctorOutput{
56+
K8sConnectivity: fmt.Sprintf("error: %v", err),
57+
}
58+
if format == "json" {
59+
enc := json.NewEncoder(os.Stdout)
60+
enc.SetIndent("", " ")
61+
_ = enc.Encode(output)
62+
} else {
63+
fmt.Printf("K8s connectivity: FAIL (%v)\n", err)
64+
}
65+
os.Exit(1)
66+
return nil
67+
}
68+
69+
services, _, err := k8sClient.FetchDeployments(cmd.Context())
70+
if err != nil {
71+
return fmt.Errorf("failed to fetch workloads: %w", err)
72+
}
73+
74+
total := len(services)
75+
coverage := computeCoverage(services)
76+
readiness := (coverage.Owner + coverage.Tier + coverage.GitOpsRepo + coverage.Oncall) / 4.0
77+
78+
var warnings []string
79+
if coverage.Owner < 0.5 {
80+
warnings = append(warnings, "less than 50% of workloads have owner annotations")
81+
}
82+
if coverage.Tier < 0.5 {
83+
warnings = append(warnings, "less than 50% of workloads have tier annotations — routing will default to standard")
84+
}
85+
if coverage.GitOpsRepo < 0.3 {
86+
warnings = append(warnings, "less than 30% of workloads have gitops-repo — agents cannot create PRs")
87+
}
88+
89+
output := DoctorOutput{
90+
K8sConnectivity: "ok",
91+
TotalWorkloads: total,
92+
Coverage: coverage,
93+
AgentReadiness: readiness,
94+
Warnings: warnings,
95+
}
96+
97+
if format == "json" {
98+
enc := json.NewEncoder(os.Stdout)
99+
enc.SetIndent("", " ")
100+
return enc.Encode(output)
101+
}
102+
103+
fmt.Printf("K8s connectivity: OK\n")
104+
fmt.Printf("Total workloads: %d\n", total)
105+
fmt.Printf("Agent readiness: %.0f%%\n\n", readiness*100)
106+
fmt.Printf("Annotation coverage:\n")
107+
fmt.Printf(" owner: %.0f%%\n", coverage.Owner*100)
108+
fmt.Printf(" tier: %.0f%%\n", coverage.Tier*100)
109+
fmt.Printf(" gitops-repo: %.0f%%\n", coverage.GitOpsRepo*100)
110+
fmt.Printf(" oncall: %.0f%%\n", coverage.Oncall*100)
111+
fmt.Printf(" runbook: %.0f%%\n", coverage.Runbook*100)
112+
fmt.Printf(" depends-on: %.0f%%\n", coverage.DependsOn*100)
113+
114+
if len(warnings) > 0 {
115+
fmt.Println("\nWarnings:")
116+
for _, w := range warnings {
117+
fmt.Printf(" ⚠ %s\n", w)
118+
}
119+
}
120+
121+
return nil
122+
},
123+
}
124+
125+
cmd.Flags().StringVar(&format, "format", "text", "Output format: text, json")
126+
return cmd
127+
}
128+
129+
func computeCoverage(services []k8s.ServiceStatus) AnnotationCoverage {
130+
total := float64(len(services))
131+
if total == 0 {
132+
return AnnotationCoverage{}
133+
}
134+
135+
var owner, tier, gitops, oncall, runbook, depends float64
136+
for _, svc := range services {
137+
if svc.Owner != nil {
138+
owner++
139+
}
140+
if svc.Tier != nil {
141+
tier++
142+
}
143+
if svc.Integration.GitOpsRepo != nil {
144+
gitops++
145+
}
146+
if svc.Integration.Oncall != nil {
147+
oncall++
148+
}
149+
if svc.Integration.Runbook != nil {
150+
runbook++
151+
}
152+
if len(svc.DependsOn) > 0 {
153+
depends++
154+
}
155+
}
156+
157+
return AnnotationCoverage{
158+
Owner: owner / total,
159+
Tier: tier / total,
160+
GitOpsRepo: gitops / total,
161+
Oncall: oncall / total,
162+
Runbook: runbook / total,
163+
DependsOn: depends / total,
164+
}
165+
}

internal/cli/init.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
)
9+
10+
const configTemplate = `# deployscope.yaml — cluster configuration
11+
cluster:
12+
name: "my-cluster"
13+
environment: "production"
14+
region: "us-east-1"
15+
16+
# Cache TTL for K8s API queries (default: 30s)
17+
# cache_ttl: 30s
18+
19+
# Label selector for workloads (default: requires app.kubernetes.io/version)
20+
# label_selector: "app.kubernetes.io/version"
21+
`
22+
23+
const annotationTemplate = `# Example deployscope.dev annotations for your Helm chart values.yaml
24+
#
25+
# Add these to your deployment/statefulset/daemonset metadata.annotations:
26+
#
27+
# deployscope:
28+
# owner: "team-platform"
29+
# tier: "critical" # critical, standard, best-effort
30+
# gitopsRepo: "github.com/org/infra"
31+
# gitopsPath: "clusters/prod/auth/"
32+
# oncall: "#platform-oncall"
33+
# runbook: "https://wiki.internal/auth-runbook"
34+
# dashboard: "https://grafana.internal/d/auth"
35+
# dependsOn: "postgres-platform,redis-shared"
36+
# healthEndpoint: "http://localhost:8080/health"
37+
#
38+
# In your Helm chart template:
39+
#
40+
# annotations:
41+
# deployscope.dev/owner: {{ .Values.deployscope.owner | quote }}
42+
# deployscope.dev/tier: {{ .Values.deployscope.tier | quote }}
43+
# deployscope.dev/gitops-repo: {{ .Values.deployscope.gitopsRepo | quote }}
44+
# deployscope.dev/gitops-path: {{ .Values.deployscope.gitopsPath | quote }}
45+
# deployscope.dev/oncall: {{ .Values.deployscope.oncall | quote }}
46+
# deployscope.dev/runbook: {{ .Values.deployscope.runbook | quote }}
47+
# deployscope.dev/dashboard: {{ .Values.deployscope.dashboard | quote }}
48+
# deployscope.dev/depends-on: {{ .Values.deployscope.dependsOn | quote }}
49+
# deployscope.dev/health-endpoint: {{ .Values.deployscope.healthEndpoint | quote }}
50+
#
51+
# To opt out a workload from deployscope:
52+
# annotations:
53+
# deployscope.dev/ignore: "true"
54+
#
55+
# Kustomize patch example:
56+
#
57+
# apiVersion: apps/v1
58+
# kind: Deployment
59+
# metadata:
60+
# name: my-service
61+
# annotations:
62+
# deployscope.dev/owner: "team-platform"
63+
# deployscope.dev/tier: "critical"
64+
`
65+
66+
func newInitCmd() *cobra.Command {
67+
return &cobra.Command{
68+
Use: "init",
69+
Short: "Generate deployscope.yaml config and example annotations",
70+
RunE: func(cmd *cobra.Command, args []string) error {
71+
configFile := "deployscope.yaml"
72+
if _, err := os.Stat(configFile); err == nil {
73+
return fmt.Errorf("%s already exists — remove it first to regenerate", configFile)
74+
}
75+
76+
if err := os.WriteFile(configFile, []byte(configTemplate), 0644); err != nil {
77+
return fmt.Errorf("failed to write %s: %w", configFile, err)
78+
}
79+
fmt.Printf("Created %s\n", configFile)
80+
81+
annotationFile := "deployscope-annotations.example.yaml"
82+
if err := os.WriteFile(annotationFile, []byte(annotationTemplate), 0644); err != nil {
83+
return fmt.Errorf("failed to write %s: %w", annotationFile, err)
84+
}
85+
fmt.Printf("Created %s\n", annotationFile)
86+
87+
fmt.Println("\nNext steps:")
88+
fmt.Println(" 1. Edit deployscope.yaml with your cluster identity")
89+
fmt.Println(" 2. Add deployscope.dev/* annotations to your Helm chart values")
90+
fmt.Println(" 3. Run 'deployscope doctor' to check annotation coverage")
91+
92+
return nil
93+
},
94+
}
95+
}

0 commit comments

Comments
 (0)