Skip to content

Commit d13b447

Browse files
author
Test
committed
feat: add last_transition timestamp, --redact flag, and doc cleanup
1 parent d821eb5 commit d13b447

5 files changed

Lines changed: 198 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## [0.2.0] - 2026-03-21
6+
7+
### Added
8+
- **Agent-native CLI** — Cobra subcommands: `serve`, `status`, `namespaces`, `init`, `doctor`, `version`
9+
- **StatefulSet and DaemonSet support** — monitors all three workload types with type-specific health logic
10+
- **Prometheus /metrics endpoint** — workload health gauges, HTTP request metrics, Go runtime collectors
11+
- **Grafana dashboards** — deployment health and self-monitoring dashboard JSON files
12+
- **Integration pointer annotations**`deployscope.dev/owner`, `tier`, `gitops-repo`, `gitops-path`, `oncall`, `runbook`, `dashboard`, `depends-on`, `health-endpoint`, `deep-health`, `deep-health-detail`
13+
- **Opt-out annotation**`deployscope.dev/ignore: "true"` makes workloads invisible to agents
14+
- **Deterministic routing** — status output includes action/reason/priority based on tier + health
15+
- **Agent readiness score**`doctor` reports annotation coverage and cluster readiness percentage
16+
- **`--format json`** — all CLI commands support structured JSON output
17+
- **`--unhealthy` filter** — status command can show only degraded/down workloads
18+
- **`--redact` flag** — scrubs sensitive values from annotation output
19+
- **`last_transition` timestamp** — per-workload transition time from K8s conditions
20+
- **SKILL.md** — ANCC-compliant agent discovery spec in docs/
21+
- **GHCR Docker image** — multi-arch (linux/amd64, linux/arm64) published on tag push
22+
23+
### Changed
24+
- Refactored main.go to delegate to Cobra CLI
25+
- RBAC updated to include statefulsets and daemonsets
26+
- Dockerfile updated to Go 1.25
27+
- go.mod updated to Go 1.25
28+
29+
## [0.1.1] - 2026-03-18
30+
31+
### Added
32+
- GHCR Docker image build in release workflow
33+
34+
### Changed
35+
- Dockerfile Go version 1.23 → 1.25
36+
37+
## [0.1.0] - 2026-02-24
38+
39+
### Added
40+
- Initial release
41+
- REST API with pagination, filtering, sorting
42+
- Embedded HTML dashboard
43+
- In-memory cache with 30s TTL
44+
- OpenAPI specification
45+
- Health and readiness probes
46+
- CORS support
47+
- Cross-platform release binaries

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ DeployScope is a read-only service that monitors all deployments in a Kubernetes
1414

1515
## What it is NOT
1616

17-
- Not a replacement for Prometheus/Grafana — shows current state, not history
18-
- Not an alerting system — display only
17+
- Not an alerting system — display only, no notifications
1918
- Not a multi-cluster solution — works within a single cluster
19+
- Not a diagnostic tool — use [kubenow](https://github.com/ppiankov/kubenow) for OOM, CrashLoop, events
20+
- Not a CMDB — mirrors K8s annotations, never invents data
2021
- No database required — everything in memory
2122

2223
## Philosophy
@@ -148,10 +149,10 @@ DeployScope is becoming the cognitive layer for autonomous Kubernetes operations
148149

149150
## Known limitations
150151

151-
- Deployments only — StatefulSets and DaemonSets support planned ([WO-5](docs/agent-native.md#scope-all-workloads-that-matter))
152152
- Single cluster only — cluster discovery is a separate tool
153153
- In-memory cache (data refreshes on restart)
154154
- No API authentication
155+
- No Jobs/CronJobs (ephemeral workloads with different lifecycle)
155156

156157
## License
157158

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/ppiankov/deployscope
22

3-
go 1.23.0
3+
go 1.25
44

55
require (
66
github.com/prometheus/client_golang v1.23.2

internal/cli/status.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"regexp"
78
"text/tabwriter"
89

910
"github.com/spf13/cobra"
1011

1112
"github.com/ppiankov/deployscope/internal/k8s"
1213
)
1314

15+
var sensitivePattern = regexp.MustCompile(`(?i)(token|secret|key|password|credential|bearer|apikey|api_key)`)
16+
var urlWithAuthPattern = regexp.MustCompile(`://[^@/]+:[^@/]+@`)
17+
1418
// StatusOutput is the structured JSON output for status command.
1519
type StatusOutput struct {
1620
Summary k8s.Summary `json:"summary"`
@@ -29,11 +33,16 @@ type RoutingAdvice struct {
2933
func newStatusCmd() *cobra.Command {
3034
var format string
3135
var unhealthy bool
36+
var redact bool
3237

3338
cmd := &cobra.Command{
3439
Use: "status",
3540
Short: "Show workload health status (one-shot, exits)",
3641
RunE: func(cmd *cobra.Command, args []string) error {
42+
if os.Getenv("DEPLOYSCOPE_REDACT") == "true" {
43+
redact = true
44+
}
45+
3746
k8sClient, err := k8s.NewClient()
3847
if err != nil {
3948
return fmt.Errorf("failed to create kubernetes client: %w", err)
@@ -54,6 +63,12 @@ func newStatusCmd() *cobra.Command {
5463
services = filtered
5564
}
5665

66+
if redact {
67+
for i := range services {
68+
redactService(&services[i])
69+
}
70+
}
71+
5772
routing := computeRouting(services, summary)
5873

5974
if format == "json" {
@@ -79,6 +94,7 @@ func newStatusCmd() *cobra.Command {
7994

8095
cmd.Flags().StringVar(&format, "format", "table", "Output format: table, json")
8196
cmd.Flags().BoolVar(&unhealthy, "unhealthy", false, "Show only degraded/down workloads")
97+
cmd.Flags().BoolVar(&redact, "redact", false, "Scrub potentially sensitive values from annotations (or set DEPLOYSCOPE_REDACT=true)")
8298

8399
return cmd
84100
}
@@ -177,3 +193,28 @@ func printStatusTable(services []k8s.ServiceStatus, summary k8s.Summary, routing
177193
}
178194
_ = w.Flush()
179195
}
196+
197+
func redactService(svc *k8s.ServiceStatus) {
198+
svc.Integration.Runbook = redactPtr(svc.Integration.Runbook)
199+
svc.Integration.Dashboard = redactPtr(svc.Integration.Dashboard)
200+
svc.Integration.HealthURL = redactPtr(svc.Integration.HealthURL)
201+
svc.Integration.DeepDetail = redactPtr(svc.Integration.DeepDetail)
202+
}
203+
204+
func redactPtr(s *string) *string {
205+
if s == nil {
206+
return nil
207+
}
208+
v := redactValue(*s)
209+
return &v
210+
}
211+
212+
func redactValue(s string) string {
213+
if urlWithAuthPattern.MatchString(s) {
214+
return urlWithAuthPattern.ReplaceAllString(s, "://***:***@")
215+
}
216+
if sensitivePattern.MatchString(s) {
217+
return "[REDACTED]"
218+
}
219+
return s
220+
}

internal/k8s/client.go

Lines changed: 105 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,25 @@ type Integration struct {
3030

3131
// ServiceStatus represents a single Kubernetes workload's status.
3232
type ServiceStatus struct {
33-
ID string `json:"id"`
34-
Name string `json:"name"`
35-
Namespace string `json:"namespace"`
36-
WorkloadType string `json:"workload_type"`
37-
Version string `json:"version"`
38-
Image string `json:"image"`
39-
Replicas int32 `json:"replicas"`
40-
ReadyReplicas int32 `json:"ready_replicas"`
41-
Status string `json:"status"`
42-
Labels map[string]string `json:"labels,omitempty"`
43-
Owner *string `json:"owner"`
44-
Tier *string `json:"tier"`
45-
ManagedBy *string `json:"managed_by"`
46-
PartOf *string `json:"part_of"`
47-
DependsOn []string `json:"depends_on"`
48-
Integration Integration `json:"integration"`
49-
CreatedAt time.Time `json:"created_at"`
50-
UpdatedAt time.Time `json:"updated_at"`
33+
ID string `json:"id"`
34+
Name string `json:"name"`
35+
Namespace string `json:"namespace"`
36+
WorkloadType string `json:"workload_type"`
37+
Version string `json:"version"`
38+
Image string `json:"image"`
39+
Replicas int32 `json:"replicas"`
40+
ReadyReplicas int32 `json:"ready_replicas"`
41+
Status string `json:"status"`
42+
Labels map[string]string `json:"labels,omitempty"`
43+
Owner *string `json:"owner"`
44+
Tier *string `json:"tier"`
45+
ManagedBy *string `json:"managed_by"`
46+
PartOf *string `json:"part_of"`
47+
DependsOn []string `json:"depends_on"`
48+
Integration Integration `json:"integration"`
49+
LastTransition *time.Time `json:"last_transition"`
50+
CreatedAt time.Time `json:"created_at"`
51+
UpdatedAt time.Time `json:"updated_at"`
5152
}
5253

5354
// Summary contains aggregate statistics.
@@ -138,25 +139,31 @@ func (c *Client) FetchDeployments(ctx context.Context) ([]ServiceStatus, Summary
138139
status := computeStatus(ready, desired)
139140
addToSummary(&summary, status)
140141

142+
var condTimes []time.Time
143+
for _, cond := range dep.Status.Conditions {
144+
condTimes = append(condTimes, cond.LastTransitionTime.Time)
145+
}
146+
141147
services = append(services, ServiceStatus{
142-
ID: fmt.Sprintf("%s/%s", dep.Namespace, dep.Name),
143-
Name: dep.Name,
144-
Namespace: dep.Namespace,
145-
WorkloadType: "deployment",
146-
Version: version,
147-
Image: image,
148-
Replicas: desired,
149-
ReadyReplicas: ready,
150-
Status: status,
151-
Labels: dep.Spec.Template.Labels,
152-
Owner: owner,
153-
Tier: tier,
154-
ManagedBy: managedBy,
155-
PartOf: partOf,
156-
DependsOn: dependsOn,
157-
Integration: integration,
158-
CreatedAt: dep.CreationTimestamp.Time,
159-
UpdatedAt: time.Now(),
148+
ID: fmt.Sprintf("%s/%s", dep.Namespace, dep.Name),
149+
Name: dep.Name,
150+
Namespace: dep.Namespace,
151+
WorkloadType: "deployment",
152+
Version: version,
153+
Image: image,
154+
Replicas: desired,
155+
ReadyReplicas: ready,
156+
Status: status,
157+
Labels: dep.Spec.Template.Labels,
158+
Owner: owner,
159+
Tier: tier,
160+
ManagedBy: managedBy,
161+
PartOf: partOf,
162+
DependsOn: dependsOn,
163+
Integration: integration,
164+
LastTransition: lastConditionTransition(condTimes),
165+
CreatedAt: dep.CreationTimestamp.Time,
166+
UpdatedAt: time.Now(),
160167
})
161168
}
162169

@@ -191,25 +198,31 @@ func (c *Client) FetchDeployments(ctx context.Context) ([]ServiceStatus, Summary
191198
status := computeStatus(ready, desired)
192199
addToSummary(&summary, status)
193200

201+
var ssTimes []time.Time
202+
for _, cond := range ss.Status.Conditions {
203+
ssTimes = append(ssTimes, cond.LastTransitionTime.Time)
204+
}
205+
194206
services = append(services, ServiceStatus{
195-
ID: fmt.Sprintf("%s/%s", ss.Namespace, ss.Name),
196-
Name: ss.Name,
197-
Namespace: ss.Namespace,
198-
WorkloadType: "statefulset",
199-
Version: version,
200-
Image: image,
201-
Replicas: desired,
202-
ReadyReplicas: ready,
203-
Status: status,
204-
Labels: ss.Spec.Template.Labels,
205-
Owner: owner,
206-
Tier: tier,
207-
ManagedBy: managedBy,
208-
PartOf: partOf,
209-
DependsOn: dependsOn,
210-
Integration: integration,
211-
CreatedAt: ss.CreationTimestamp.Time,
212-
UpdatedAt: time.Now(),
207+
ID: fmt.Sprintf("%s/%s", ss.Namespace, ss.Name),
208+
Name: ss.Name,
209+
Namespace: ss.Namespace,
210+
WorkloadType: "statefulset",
211+
Version: version,
212+
Image: image,
213+
Replicas: desired,
214+
ReadyReplicas: ready,
215+
Status: status,
216+
Labels: ss.Spec.Template.Labels,
217+
Owner: owner,
218+
Tier: tier,
219+
ManagedBy: managedBy,
220+
PartOf: partOf,
221+
DependsOn: dependsOn,
222+
Integration: integration,
223+
LastTransition: lastConditionTransition(ssTimes),
224+
CreatedAt: ss.CreationTimestamp.Time,
225+
UpdatedAt: time.Now(),
213226
})
214227
}
215228
}
@@ -242,25 +255,31 @@ func (c *Client) FetchDeployments(ctx context.Context) ([]ServiceStatus, Summary
242255
status := computeStatus(ready, desired)
243256
addToSummary(&summary, status)
244257

258+
var dsTimes []time.Time
259+
for _, cond := range ds.Status.Conditions {
260+
dsTimes = append(dsTimes, cond.LastTransitionTime.Time)
261+
}
262+
245263
services = append(services, ServiceStatus{
246-
ID: fmt.Sprintf("%s/%s", ds.Namespace, ds.Name),
247-
Name: ds.Name,
248-
Namespace: ds.Namespace,
249-
WorkloadType: "daemonset",
250-
Version: version,
251-
Image: image,
252-
Replicas: desired,
253-
ReadyReplicas: ready,
254-
Status: status,
255-
Labels: ds.Spec.Template.Labels,
256-
Owner: owner,
257-
Tier: tier,
258-
ManagedBy: managedBy,
259-
PartOf: partOf,
260-
DependsOn: dependsOn,
261-
Integration: integration,
262-
CreatedAt: ds.CreationTimestamp.Time,
263-
UpdatedAt: time.Now(),
264+
ID: fmt.Sprintf("%s/%s", ds.Namespace, ds.Name),
265+
Name: ds.Name,
266+
Namespace: ds.Namespace,
267+
WorkloadType: "daemonset",
268+
Version: version,
269+
Image: image,
270+
Replicas: desired,
271+
ReadyReplicas: ready,
272+
Status: status,
273+
Labels: ds.Spec.Template.Labels,
274+
Owner: owner,
275+
Tier: tier,
276+
ManagedBy: managedBy,
277+
PartOf: partOf,
278+
DependsOn: dependsOn,
279+
Integration: integration,
280+
LastTransition: lastConditionTransition(dsTimes),
281+
CreatedAt: ds.CreationTimestamp.Time,
282+
UpdatedAt: time.Now(),
264283
})
265284
}
266285
}
@@ -304,6 +323,20 @@ func addToSummary(summary *Summary, status string) {
304323
}
305324
}
306325

326+
// lastConditionTransition returns the most recent condition transition time.
327+
func lastConditionTransition(times []time.Time) *time.Time {
328+
if len(times) == 0 {
329+
return nil
330+
}
331+
latest := times[0]
332+
for _, t := range times[1:] {
333+
if t.After(latest) {
334+
latest = t
335+
}
336+
}
337+
return &latest
338+
}
339+
307340
const annotationPrefix = "deployscope.dev/"
308341

309342
func mergeAnnotations(sets ...map[string]string) map[string]string {

0 commit comments

Comments
 (0)