mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 18:58:56 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6515b4323 | |||
| 11173a74c6 | |||
| ec0e7a3560 |
@@ -226,6 +226,7 @@ func main() {
|
|||||||
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||||
certificateService.SetTargetRepo(targetRepo)
|
certificateService.SetTargetRepo(targetRepo)
|
||||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||||
|
renewalService.SetTargetRepo(targetRepo)
|
||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
|
|||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
|
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
|
||||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work. For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work and receive only jobs assigned to their targets (routed via `agent_id` on jobs or through target→agent relationships). For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||||
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
|
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
|
||||||
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
|
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
|
||||||
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
||||||
|
|||||||
+145
-4
@@ -39,6 +39,9 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
|
|||||||
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
|
||||||
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
|
||||||
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
|
||||||
|
- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration)
|
||||||
|
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
|
||||||
|
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
||||||
- [Release Sign-Off](#release-sign-off)
|
- [Release Sign-Off](#release-sign-off)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -5069,6 +5072,100 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Part 35: ARI (RFC 9702) Scheduler Integration
|
||||||
|
|
||||||
|
Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates.
|
||||||
|
|
||||||
|
### 35.1 ARI Defers Renewal When CA Says "Not Yet"
|
||||||
|
|
||||||
|
**Prerequisite:** ACME issuer configured with `CERTCTL_ACME_ARI_ENABLED=true`, connected to a CA that supports ARI (e.g., Let's Encrypt staging). Certificate within the 30-day expiry window but the CA's `suggestedWindow.start` is in the future.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check scheduler logs for ARI deferral
|
||||||
|
docker logs certctl-server 2>&1 | grep "ARI: renewal not yet suggested"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** Log line showing `ARI: renewal not yet suggested by CA` with `cert_id`, `suggested_start`, `suggested_end`. No renewal job created for that cert.
|
||||||
|
**PASS if** the scheduler skips renewal job creation when ARI says the window hasn't opened.
|
||||||
|
|
||||||
|
### 35.2 ARI Triggers Renewal When CA Says "Now"
|
||||||
|
|
||||||
|
**Prerequisite:** Same setup as 35.1, but the certificate's ARI `suggestedWindow.start` is in the past (CA is actively suggesting renewal).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check scheduler logs for ARI-triggered renewal
|
||||||
|
docker logs certctl-server 2>&1 | grep "ARI: CA suggests renewal now"
|
||||||
|
|
||||||
|
# Verify renewal job was created
|
||||||
|
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
|
"http://localhost:8443/api/v1/jobs?type=renewal" | jq '.data[] | select(.certificate_id == "<cert-id>")'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** Log line showing `ARI: CA suggests renewal now`. Renewal job created with `renewal_trigger: ari` in the audit trail.
|
||||||
|
**PASS if** a renewal job is created when ARI indicates the renewal window is open.
|
||||||
|
|
||||||
|
### 35.3 ARI Fallback on Error
|
||||||
|
|
||||||
|
**Prerequisite:** ACME issuer with `CERTCTL_ACME_ARI_ENABLED=true`, but the ARI endpoint is unreachable or returns an error (e.g., network issue, 500 from CA).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check scheduler logs for ARI fallback
|
||||||
|
docker logs certctl-server 2>&1 | grep "ARI check failed, falling back"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** Warning log `ARI check failed, falling back to threshold-based renewal`. Renewal proceeds normally using the configured expiration thresholds.
|
||||||
|
**PASS if** renewal still works when ARI is unavailable, using threshold-based logic as fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 36: Agent Work Routing (M31)
|
||||||
|
|
||||||
|
Tests that `GetPendingWork()` returns only jobs scoped to the requesting agent, and that deployment jobs have `agent_id` populated at creation time.
|
||||||
|
|
||||||
|
### 36.1 Multi-Agent Routing
|
||||||
|
|
||||||
|
**Prerequisite:** Two agents registered (`agent-web-01`, `agent-lb-01`), two targets (one per agent), one certificate mapped to both targets. Trigger renewal to create deployment jobs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Poll as agent-web-01 — should only see its deployment job
|
||||||
|
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
|
"http://localhost:8443/api/v1/agents/agent-web-01/work" | jq '.[] | .target_id'
|
||||||
|
|
||||||
|
# Poll as agent-lb-01 — should only see its deployment job
|
||||||
|
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
|
"http://localhost:8443/api/v1/agents/agent-lb-01/work" | jq '.[] | .target_id'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** Each agent receives only the deployment job for its assigned target. Agent-web-01 does NOT see agent-lb-01's job and vice versa.
|
||||||
|
**PASS if** each agent's work response contains only jobs for targets it owns.
|
||||||
|
|
||||||
|
### 36.2 Agent With No Targets Gets Empty Work
|
||||||
|
|
||||||
|
**Prerequisite:** Register a new agent with no target assignments.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
|
"http://localhost:8443/api/v1/agents/agent-no-targets/work" | jq 'length'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** Empty array (0 jobs).
|
||||||
|
**PASS if** the response is an empty list.
|
||||||
|
|
||||||
|
### 36.3 Deployment Jobs Have agent_id Populated
|
||||||
|
|
||||||
|
**Prerequisite:** Deployment jobs created via renewal or manual trigger.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check that deployment jobs in the system have agent_id set
|
||||||
|
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||||
|
"http://localhost:8443/api/v1/jobs" | jq '[.data[] | select(.type == "Deployment") | .agent_id] | map(select(. != null)) | length'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** All deployment jobs for targets with agent assignments have `agent_id` populated.
|
||||||
|
**PASS if** deployment jobs have non-null `agent_id` values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Release Sign-Off
|
## Release Sign-Off
|
||||||
|
|
||||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||||
@@ -5082,7 +5179,7 @@ These must be green before starting manual QA:
|
|||||||
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
||||||
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
||||||
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
|
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
|
||||||
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 121 pass, 0 fail, 5 skip |
|
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 124 pass, 0 fail, 5 skip |
|
||||||
|
|
||||||
### Part 1: Infrastructure & Deployment
|
### Part 1: Infrastructure & Deployment
|
||||||
|
|
||||||
@@ -5574,14 +5671,58 @@ These must be green before starting manual QA:
|
|||||||
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
||||||
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
||||||
|
|
||||||
|
### Part 35: ARI (RFC 9702) Scheduler Integration
|
||||||
|
|
||||||
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|
|------|-------------|--------|-------|------|-------|
|
||||||
|
| 35.a1 | ARI nil fallback — renewal jobs still created | Auto | ☑ | 2026-03-30 | |
|
||||||
|
| 35.a2 | No ARI errors with Local CA issuer | Auto | ☑ | 2026-03-30 | |
|
||||||
|
| 35.a3 | Server healthy after ARI wiring (metrics) | Auto | ☑ | 2026-03-30 | |
|
||||||
|
| 35.1 | ARI defers renewal when CA says "not yet" (requires ACME+ARI) | Manual | ☐ | | |
|
||||||
|
| 35.2 | ARI triggers renewal when CA says "now" (requires ACME+ARI) | Manual | ☐ | | |
|
||||||
|
| 35.3 | ARI fallback on error — threshold-based (requires ACME+ARI) | Manual | ☐ | | |
|
||||||
|
|
||||||
|
### Part 36: Agent Work Routing (M31)
|
||||||
|
|
||||||
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|
|------|-------------|--------|-------|------|-------|
|
||||||
|
| 36.a1 | Agent receives only its deployment jobs | Auto | ☐ | | |
|
||||||
|
| 36.a2 | Agent with no targets gets empty work list | Auto | ☐ | | |
|
||||||
|
| 36.a3 | Deployment jobs have agent_id populated | Auto | ☐ | | |
|
||||||
|
| 36.1 | Multi-agent routing with 2 agents, 2 targets | Manual | ☐ | | |
|
||||||
|
| 36.2 | Agent with no assigned targets gets empty work | Manual | ☐ | | |
|
||||||
|
| 36.3 | Database agent_id populated on deployment jobs | Manual | ☐ | | |
|
||||||
|
|
||||||
|
### Part 37: GUI Completeness (Pre-2.1.0-E)
|
||||||
|
|
||||||
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|
|------|-------------|--------|-------|------|-------|
|
||||||
|
| 37.1 | DigestPage renders preview iframe | Manual | ☐ | | |
|
||||||
|
| 37.2 | DigestPage send button with confirmation modal | Manual | ☐ | | |
|
||||||
|
| 37.3 | ObservabilityPage shows metrics gauges | Manual | ☐ | | |
|
||||||
|
| 37.4 | ObservabilityPage Prometheus config block | Manual | ☐ | | |
|
||||||
|
| 37.5 | ObservabilityPage live Prometheus output | Manual | ☐ | | |
|
||||||
|
| 37.6 | JobDetailPage displays job info and timeline | Manual | ☐ | | |
|
||||||
|
| 37.7 | JobDetailPage verification section for deployment jobs | Manual | ☐ | | |
|
||||||
|
| 37.8 | IssuerDetailPage shows redacted config | Manual | ☐ | | |
|
||||||
|
| 37.9 | IssuerDetailPage test connection button | Manual | ☐ | | |
|
||||||
|
| 37.10 | IssuerDetailPage issued certificates list | Manual | ☐ | | |
|
||||||
|
| 37.11 | TargetDetailPage shows config and agent link | Manual | ☐ | | |
|
||||||
|
| 37.12 | TargetDetailPage deployment history table | Manual | ☐ | | |
|
||||||
|
| 37.13 | JobsPage — job IDs clickable to /jobs/:id | Manual | ☐ | | |
|
||||||
|
| 37.14 | JobsPage — verification column for deployment jobs | Manual | ☐ | | |
|
||||||
|
| 37.15 | IssuersPage — issuer names clickable to /issuers/:id | Manual | ☐ | | |
|
||||||
|
| 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | |
|
||||||
|
| 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | |
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|
||||||
| Category | Count |
|
| Category | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 121 |
|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 127 |
|
||||||
| — Skipped (preconditions not met in demo) | 5 |
|
| — Skipped (preconditions not met in demo) | 5 |
|
||||||
| ☐ Manual (requires hands-on verification) | 194 |
|
| ☐ Manual (requires hands-on verification) | 217 |
|
||||||
| **Total** | **320** |
|
| **Total** | **349** |
|
||||||
|
|
||||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Job struct {
|
|||||||
Type JobType `json:"type"`
|
Type JobType `json:"type"`
|
||||||
CertificateID string `json:"certificate_id"`
|
CertificateID string `json:"certificate_id"`
|
||||||
TargetID *string `json:"target_id,omitempty"`
|
TargetID *string `json:"target_id,omitempty"`
|
||||||
|
AgentID *string `json:"agent_id,omitempty"`
|
||||||
Status JobStatus `json:"status"`
|
Status JobStatus `json:"status"`
|
||||||
Attempts int `json:"attempts"`
|
Attempts int `json:"attempts"`
|
||||||
MaxAttempts int `json:"max_attempts"`
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
|||||||
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
|
|||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
|
var result []*domain.Job
|
||||||
|
for _, j := range m.jobs {
|
||||||
|
if j.AgentID != nil && *j.AgentID == agentID {
|
||||||
|
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||||
|
result = append(result, j)
|
||||||
|
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||||
|
result = append(result, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockAuditRepository struct {
|
type mockAuditRepository struct {
|
||||||
events []*domain.AuditEvent
|
events []*domain.AuditEvent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ type JobRepository interface {
|
|||||||
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
||||||
// GetPendingJobs returns jobs not yet processed of a specific type.
|
// GetPendingJobs returns jobs not yet processed of a specific type.
|
||||||
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
||||||
|
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||||
|
ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewalPolicyRepository defines operations for managing renewal policies.
|
// RenewalPolicyRepository defines operations for managing renewal policies.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func NewJobRepository(db *sql.DB) *JobRepository {
|
|||||||
// List returns all jobs
|
// List returns all jobs
|
||||||
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -52,7 +52,7 @@ func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
|||||||
// Get retrieves a job by ID
|
// Get retrieves a job by ID
|
||||||
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -77,11 +77,11 @@ func (r *JobRepository) Create(ctx context.Context, job *domain.Job) error {
|
|||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO jobs (
|
INSERT INTO jobs (
|
||||||
id, type, certificate_id, target_id, status, attempts, max_attempts,
|
id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
|
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
|
||||||
job.CreatedAt).Scan(&job.ID)
|
job.CreatedAt).Scan(&job.ID)
|
||||||
|
|
||||||
@@ -99,15 +99,16 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
|
|||||||
type = $1,
|
type = $1,
|
||||||
certificate_id = $2,
|
certificate_id = $2,
|
||||||
target_id = $3,
|
target_id = $3,
|
||||||
status = $4,
|
agent_id = $4,
|
||||||
attempts = $5,
|
status = $5,
|
||||||
max_attempts = $6,
|
attempts = $6,
|
||||||
last_error = $7,
|
max_attempts = $7,
|
||||||
scheduled_at = $8,
|
last_error = $8,
|
||||||
started_at = $9,
|
scheduled_at = $9,
|
||||||
completed_at = $10
|
started_at = $10,
|
||||||
WHERE id = $11
|
completed_at = $11
|
||||||
`, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
WHERE id = $12
|
||||||
|
`, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
||||||
job.CompletedAt, job.ID)
|
job.CompletedAt, job.ID)
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
|
|||||||
// ListByStatus returns jobs with a specific status
|
// ListByStatus returns jobs with a specific status
|
||||||
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE status = $1
|
WHERE status = $1
|
||||||
@@ -181,7 +182,7 @@ func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatu
|
|||||||
// ListByCertificate returns all jobs for a certificate
|
// ListByCertificate returns all jobs for a certificate
|
||||||
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE certificate_id = $1
|
WHERE certificate_id = $1
|
||||||
@@ -239,7 +240,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
|
|||||||
// GetPendingJobs returns jobs not yet processed of a specific type
|
// GetPendingJobs returns jobs not yet processed of a specific type
|
||||||
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE type = $1 AND status = $2
|
WHERE type = $1 AND status = $2
|
||||||
@@ -267,13 +268,71 @@ func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobTy
|
|||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||||
|
// Deployment jobs are matched by agent_id directly (set at creation time), with a fallback
|
||||||
|
// for legacy jobs where agent_id is NULL but target_id resolves to the agent via deployment_targets.
|
||||||
|
// AwaitingCSR jobs are matched through certificate → target mappings → agent ownership.
|
||||||
|
func (r *JobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE agent_id = $1 AND status = 'Pending' AND type = 'Deployment'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||||
|
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||||
|
FROM jobs j
|
||||||
|
INNER JOIN deployment_targets dt ON j.target_id = dt.id
|
||||||
|
WHERE j.agent_id IS NULL AND j.status = 'Pending' AND j.type = 'Deployment'
|
||||||
|
AND dt.agent_id = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||||
|
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||||
|
FROM jobs j
|
||||||
|
WHERE j.status = 'AwaitingCSR'
|
||||||
|
AND j.type IN ('Renewal', 'Issuance')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM certificate_target_mappings ctm
|
||||||
|
INNER JOIN deployment_targets dt ON ctm.target_id = dt.id
|
||||||
|
WHERE ctm.certificate_id = j.certificate_id
|
||||||
|
AND dt.agent_id = $1
|
||||||
|
)
|
||||||
|
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`, agentID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query pending jobs for agent: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var jobs []*domain.Job
|
||||||
|
for rows.Next() {
|
||||||
|
job, err := scanJob(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error iterating pending agent job rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// scanJob scans a job from a row or rows
|
// scanJob scans a job from a row or rows
|
||||||
func scanJob(scanner interface {
|
func scanJob(scanner interface {
|
||||||
Scan(...interface{}) error
|
Scan(...interface{}) error
|
||||||
}) (*domain.Job, error) {
|
}) (*domain.Job, error) {
|
||||||
var job domain.Job
|
var job domain.Job
|
||||||
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
|
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
|
||||||
&job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
&job.AgentID, &job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
||||||
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -251,38 +251,17 @@ func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID strin
|
|||||||
|
|
||||||
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
||||||
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
||||||
|
// Jobs are scoped to the requesting agent via agent_id (set at job creation) or
|
||||||
|
// through target→agent relationships for legacy jobs and AwaitingCSR routing.
|
||||||
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
// Fetch agent to verify it exists
|
// Verify agent exists
|
||||||
_, err := s.agentRepo.Get(ctx, agentID)
|
_, err := s.agentRepo.Get(ctx, agentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var workForAgent []*domain.Job
|
// Return only jobs assigned to this agent (via agent_id or target→agent relationship)
|
||||||
|
return s.jobRepo.ListPendingByAgentID(ctx, agentID)
|
||||||
// Get pending deployment jobs
|
|
||||||
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
|
|
||||||
}
|
|
||||||
for _, job := range pendingJobs {
|
|
||||||
if job.Type == domain.JobTypeDeployment {
|
|
||||||
workForAgent = append(workForAgent, job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get AwaitingCSR jobs (agent keygen mode — agent needs to generate key + submit CSR)
|
|
||||||
awaitingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusAwaitingCSR)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list awaiting CSR jobs: %w", err)
|
|
||||||
}
|
|
||||||
for _, job := range awaitingJobs {
|
|
||||||
if job.Type == domain.JobTypeRenewal || job.Type == domain.JobTypeIssuance {
|
|
||||||
workForAgent = append(workForAgent, job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return workForAgent, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportJobStatus updates a job's status based on agent feedback.
|
// ReportJobStatus updates a job's status based on agent feedback.
|
||||||
|
|||||||
@@ -131,8 +131,9 @@ func TestHeartbeat_NotFound(t *testing.T) {
|
|||||||
func TestGetPendingWork(t *testing.T) {
|
func TestGetPendingWork(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
agentID := "agent-001"
|
||||||
agent := &domain.Agent{
|
agent := &domain.Agent{
|
||||||
ID: "agent-001",
|
ID: agentID,
|
||||||
Name: "prod-agent",
|
Name: "prod-agent",
|
||||||
Hostname: "server-01",
|
Hostname: "server-01",
|
||||||
Status: domain.AgentStatusOnline,
|
Status: domain.AgentStatusOnline,
|
||||||
@@ -146,6 +147,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
Type: domain.JobTypeDeployment,
|
Type: domain.JobTypeDeployment,
|
||||||
CertificateID: "cert-001",
|
CertificateID: "cert-001",
|
||||||
Status: domain.JobStatusPending,
|
Status: domain.JobStatusPending,
|
||||||
|
AgentID: &agentID,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
job2 := &domain.Job{
|
job2 := &domain.Job{
|
||||||
@@ -157,7 +159,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agentRepo := &mockAgentRepo{
|
agentRepo := &mockAgentRepo{
|
||||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
Agents: map[string]*domain.Agent{agentID: agent},
|
||||||
HeartbeatUpdates: make(map[string]time.Time),
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
certRepo := &mockCertRepo{
|
certRepo := &mockCertRepo{
|
||||||
@@ -177,7 +179,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
jobs, err := agentService.GetPendingWork(ctx, "agent-001")
|
jobs, err := agentService.GetPendingWork(ctx, agentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetPendingWork failed: %v", err)
|
t.Fatalf("GetPendingWork failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -185,11 +187,132 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
if len(jobs) != 1 {
|
if len(jobs) != 1 {
|
||||||
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
||||||
}
|
}
|
||||||
if jobs[0].Type != domain.JobTypeDeployment {
|
if len(jobs) > 0 && jobs[0].Type != domain.JobTypeDeployment {
|
||||||
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
|
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
agentA := "agent-A"
|
||||||
|
agentB := "agent-B"
|
||||||
|
|
||||||
|
agentRepo := &mockAgentRepo{
|
||||||
|
Agents: map[string]*domain.Agent{
|
||||||
|
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||||
|
agentB: {ID: agentB, Name: "agent-B", Hostname: "host-b", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashB"},
|
||||||
|
},
|
||||||
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
jobA := &domain.Job{ID: "job-A", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||||
|
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-002", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||||
|
|
||||||
|
jobRepo := &mockJobRepo{
|
||||||
|
Jobs: map[string]*domain.Job{"job-A": jobA, "job-B": jobB},
|
||||||
|
StatusUpdates: make(map[string]domain.JobStatus),
|
||||||
|
}
|
||||||
|
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||||
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||||
|
|
||||||
|
// Agent A should only see its job
|
||||||
|
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork for agent-A failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobsA) != 1 {
|
||||||
|
t.Fatalf("expected 1 job for agent-A, got %d", len(jobsA))
|
||||||
|
}
|
||||||
|
if jobsA[0].ID != "job-A" {
|
||||||
|
t.Errorf("expected job-A, got %s", jobsA[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent B should only see its job
|
||||||
|
jobsB, err := agentService.GetPendingWork(ctx, agentB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork for agent-B failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobsB) != 1 {
|
||||||
|
t.Fatalf("expected 1 job for agent-B, got %d", len(jobsB))
|
||||||
|
}
|
||||||
|
if jobsB[0].ID != "job-B" {
|
||||||
|
t.Errorf("expected job-B, got %s", jobsB[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
agentA := "agent-A"
|
||||||
|
agentB := "agent-B"
|
||||||
|
|
||||||
|
agentRepo := &mockAgentRepo{
|
||||||
|
Agents: map[string]*domain.Agent{
|
||||||
|
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||||
|
},
|
||||||
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
// All jobs belong to agent-B
|
||||||
|
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||||
|
|
||||||
|
jobRepo := &mockJobRepo{
|
||||||
|
Jobs: map[string]*domain.Job{"job-B": jobB},
|
||||||
|
StatusUpdates: make(map[string]domain.JobStatus),
|
||||||
|
}
|
||||||
|
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||||
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||||
|
|
||||||
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobs) != 0 {
|
||||||
|
t.Errorf("expected 0 jobs for agent-A (all jobs are for agent-B), got %d", len(jobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
agentA := "agent-A"
|
||||||
|
|
||||||
|
agentRepo := &mockAgentRepo{
|
||||||
|
Agents: map[string]*domain.Agent{
|
||||||
|
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||||
|
},
|
||||||
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
deployJob := &domain.Job{ID: "job-deploy", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||||
|
csrJob := &domain.Job{ID: "job-csr", Type: domain.JobTypeRenewal, CertificateID: "cert-002", Status: domain.JobStatusAwaitingCSR, AgentID: &agentA, CreatedAt: now}
|
||||||
|
|
||||||
|
jobRepo := &mockJobRepo{
|
||||||
|
Jobs: map[string]*domain.Job{"job-deploy": deployJob, "job-csr": csrJob},
|
||||||
|
StatusUpdates: make(map[string]domain.JobStatus),
|
||||||
|
}
|
||||||
|
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||||
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||||
|
|
||||||
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobs) != 2 {
|
||||||
|
t.Fatalf("expected 2 jobs (deployment + AwaitingCSR), got %d", len(jobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReportJobStatus(t *testing.T) {
|
func TestReportJobStatus(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID str
|
|||||||
if target.ID != "" {
|
if target.ID != "" {
|
||||||
job.TargetID = &target.ID
|
job.TargetID = &target.ID
|
||||||
}
|
}
|
||||||
|
// Route job to the target's assigned agent
|
||||||
|
if target.AgentID != "" {
|
||||||
|
agentID := target.AgentID
|
||||||
|
job.AgentID = &agentID
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||||
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
||||||
|
|||||||
@@ -85,6 +85,45 @@ func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
|
|||||||
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
||||||
t.Errorf("expected job to have TargetID set")
|
t.Errorf("expected job to have TargetID set")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M31: Verify AgentID is set from target's agent assignment
|
||||||
|
if job.AgentID == nil {
|
||||||
|
t.Errorf("expected job to have AgentID set (M31 agent routing)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeploymentService_CreateDeploymentJobs_SetsAgentID verifies AgentID is populated from target.
|
||||||
|
func TestDeploymentService_CreateDeploymentJobs_SetsAgentID(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "tgt-nginx-1",
|
||||||
|
Name: "NGINX Server 1",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-web-01",
|
||||||
|
Enabled: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDeploymentJobs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(jobIDs) != 1 {
|
||||||
|
t.Fatalf("expected 1 job, got %d", len(jobIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
job := jobRepo.Jobs[jobIDs[0]]
|
||||||
|
if job.AgentID == nil {
|
||||||
|
t.Fatal("expected AgentID to be set on deployment job")
|
||||||
|
}
|
||||||
|
if *job.AgentID != "agent-web-01" {
|
||||||
|
t.Errorf("expected AgentID 'agent-web-01', got '%s'", *job.AgentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ type RenewalService struct {
|
|||||||
jobRepo repository.JobRepository
|
jobRepo repository.JobRepository
|
||||||
renewalPolicyRepo repository.RenewalPolicyRepository
|
renewalPolicyRepo repository.RenewalPolicyRepository
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
|
targetRepo repository.TargetRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
notificationSvc *NotificationService
|
notificationSvc *NotificationService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry map[string]IssuerConnector
|
||||||
keygenMode string // "agent" (default) or "server" (demo only)
|
keygenMode string // "agent" (default) or "server" (demo only)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTargetRepo sets the target repository for resolving agent_id on deployment jobs.
|
||||||
|
func (s *RenewalService) SetTargetRepo(repo repository.TargetRepository) {
|
||||||
|
s.targetRepo = repo
|
||||||
|
}
|
||||||
|
|
||||||
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
|
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
|
||||||
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
|
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
|
||||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
||||||
@@ -163,10 +169,39 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
|||||||
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
|
||||||
|
|
||||||
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
||||||
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer {
|
connector, hasIssuer := s.issuerRegistry[cert.IssuerID]
|
||||||
|
if !hasIssuer {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ARI check (RFC 9702): if the issuer supports ARI, let the CA direct renewal timing.
|
||||||
|
// Fetch the latest cert version to get the PEM chain for the ARI query.
|
||||||
|
ariChecked := false
|
||||||
|
if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" {
|
||||||
|
if ariResult, ariErr := connector.GetRenewalInfo(ctx, version.PEMChain); ariErr != nil {
|
||||||
|
// ARI error is non-fatal — log and fall through to threshold-based renewal
|
||||||
|
slog.Warn("ARI check failed, falling back to threshold-based renewal",
|
||||||
|
"cert_id", cert.ID, "issuer_id", cert.IssuerID, "error", ariErr)
|
||||||
|
} else if ariResult != nil {
|
||||||
|
ariChecked = true
|
||||||
|
now := time.Now()
|
||||||
|
if now.Before(ariResult.SuggestedWindowStart) {
|
||||||
|
// CA says it's too early to renew — skip this cert
|
||||||
|
slog.Debug("ARI: renewal not yet suggested by CA",
|
||||||
|
"cert_id", cert.ID,
|
||||||
|
"suggested_start", ariResult.SuggestedWindowStart,
|
||||||
|
"suggested_end", ariResult.SuggestedWindowEnd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("ARI: CA suggests renewal now",
|
||||||
|
"cert_id", cert.ID,
|
||||||
|
"suggested_start", ariResult.SuggestedWindowStart,
|
||||||
|
"suggested_end", ariResult.SuggestedWindowEnd)
|
||||||
|
}
|
||||||
|
// ariResult == nil means issuer doesn't support ARI — fall through to threshold logic
|
||||||
|
}
|
||||||
|
_ = ariChecked // used for audit metadata below
|
||||||
|
|
||||||
// Check for existing pending/running renewal jobs to avoid duplicates
|
// Check for existing pending/running renewal jobs to avoid duplicates
|
||||||
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
|
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -206,9 +241,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Record audit event
|
// Record audit event
|
||||||
|
auditMeta := map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}
|
||||||
|
if ariChecked {
|
||||||
|
auditMeta["renewal_trigger"] = "ari"
|
||||||
|
}
|
||||||
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||||
"renewal_job_created", "certificate", cert.ID,
|
"renewal_job_created", "certificate", cert.ID, auditMeta); auditErr != nil {
|
||||||
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}); auditErr != nil {
|
|
||||||
slog.Error("failed to record audit event", "error", auditErr)
|
slog.Error("failed to record audit event", "error", auditErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,12 +642,26 @@ func (s *RenewalService) createDeploymentJobs(ctx context.Context, cert *domain.
|
|||||||
}
|
}
|
||||||
for _, targetID := range cert.TargetIDs {
|
for _, targetID := range cert.TargetIDs {
|
||||||
tid := targetID
|
tid := targetID
|
||||||
|
|
||||||
|
// Resolve agent_id from target for job routing
|
||||||
|
var agentIDPtr *string
|
||||||
|
if s.targetRepo != nil {
|
||||||
|
target, err := s.targetRepo.Get(ctx, tid)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to resolve agent for deployment job", "target_id", tid, "error", err)
|
||||||
|
} else if target.AgentID != "" {
|
||||||
|
agentID := target.AgentID
|
||||||
|
agentIDPtr = &agentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deployJob := &domain.Job{
|
deployJob := &domain.Job{
|
||||||
ID: generateID("job"),
|
ID: generateID("job"),
|
||||||
CertificateID: cert.ID,
|
CertificateID: cert.ID,
|
||||||
Type: domain.JobTypeDeployment,
|
Type: domain.JobTypeDeployment,
|
||||||
Status: domain.JobStatusPending,
|
Status: domain.JobStatusPending,
|
||||||
TargetID: &tid,
|
TargetID: &tid,
|
||||||
|
AgentID: agentIDPtr,
|
||||||
MaxAttempts: 3,
|
MaxAttempts: 3,
|
||||||
ScheduledAt: time.Now(),
|
ScheduledAt: time.Now(),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
|
|||||||
@@ -863,4 +863,283 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ARI (RFC 9702) Scheduler Integration Tests ---
|
||||||
|
|
||||||
|
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
jobRepo := newMockJobRepository()
|
||||||
|
policyRepo := newMockRenewalPolicyRepository()
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
notifRepo := newMockNotificationRepository()
|
||||||
|
|
||||||
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
|
// ARI says renew now: window started in the past
|
||||||
|
ariConnector := &mockIssuerConnector{
|
||||||
|
getRenewalInfoResult: &RenewalInfoResult{
|
||||||
|
SuggestedWindowStart: time.Now().Add(-24 * time.Hour),
|
||||||
|
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
issuerRegistry := map[string]IssuerConnector{
|
||||||
|
"iss-acme": ariConnector,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
|
// Create cert expiring in 20 days with a cert version (needed for ARI lookup)
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "mc-ari-renew",
|
||||||
|
Name: "ARI Cert",
|
||||||
|
CommonName: "ari.example.com",
|
||||||
|
SANs: []string{},
|
||||||
|
OwnerID: "owner-1",
|
||||||
|
TeamID: "team-1",
|
||||||
|
IssuerID: "iss-acme",
|
||||||
|
RenewalPolicyID: "rp-standard",
|
||||||
|
Status: domain.CertificateStatusActive,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
||||||
|
Tags: make(map[string]string),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||||
|
{ID: "cv-1", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := &domain.RenewalPolicy{
|
||||||
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||||
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||||
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||||
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
policyRepo.AddPolicy(policy)
|
||||||
|
|
||||||
|
err := svc.CheckExpiringCertificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARI says renew now, so a renewal job should be created
|
||||||
|
hasRenewalJob := false
|
||||||
|
for _, job := range jobRepo.Jobs {
|
||||||
|
if job.Type == domain.JobTypeRenewal {
|
||||||
|
hasRenewalJob = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRenewalJob {
|
||||||
|
t.Errorf("expected renewal job when ARI ShouldRenewNow is true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
jobRepo := newMockJobRepository()
|
||||||
|
policyRepo := newMockRenewalPolicyRepository()
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
notifRepo := newMockNotificationRepository()
|
||||||
|
|
||||||
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
|
// ARI says NOT yet: window starts in the future
|
||||||
|
ariConnector := &mockIssuerConnector{
|
||||||
|
getRenewalInfoResult: &RenewalInfoResult{
|
||||||
|
SuggestedWindowStart: time.Now().Add(72 * time.Hour),
|
||||||
|
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
issuerRegistry := map[string]IssuerConnector{
|
||||||
|
"iss-acme": ariConnector,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
|
// Cert is within the 30-day threshold window (would normally trigger renewal),
|
||||||
|
// but ARI says "not yet"
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "mc-ari-wait",
|
||||||
|
Name: "ARI Wait Cert",
|
||||||
|
CommonName: "ari-wait.example.com",
|
||||||
|
SANs: []string{},
|
||||||
|
OwnerID: "owner-1",
|
||||||
|
TeamID: "team-1",
|
||||||
|
IssuerID: "iss-acme",
|
||||||
|
RenewalPolicyID: "rp-standard",
|
||||||
|
Status: domain.CertificateStatusActive,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
||||||
|
Tags: make(map[string]string),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||||
|
{ID: "cv-2", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := &domain.RenewalPolicy{
|
||||||
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||||
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||||
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||||
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
policyRepo.AddPolicy(policy)
|
||||||
|
|
||||||
|
err := svc.CheckExpiringCertificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARI says not yet, so NO renewal job should be created
|
||||||
|
for _, job := range jobRepo.Jobs {
|
||||||
|
if job.Type == domain.JobTypeRenewal {
|
||||||
|
t.Errorf("expected no renewal job when ARI says not yet, but found one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
jobRepo := newMockJobRepository()
|
||||||
|
policyRepo := newMockRenewalPolicyRepository()
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
notifRepo := newMockNotificationRepository()
|
||||||
|
|
||||||
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
|
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
||||||
|
issuerRegistry := map[string]IssuerConnector{
|
||||||
|
"iss-local": &mockIssuerConnector{},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "mc-ari-nil",
|
||||||
|
Name: "No ARI Cert",
|
||||||
|
CommonName: "no-ari.example.com",
|
||||||
|
SANs: []string{},
|
||||||
|
OwnerID: "owner-1",
|
||||||
|
TeamID: "team-1",
|
||||||
|
IssuerID: "iss-local",
|
||||||
|
RenewalPolicyID: "rp-standard",
|
||||||
|
Status: domain.CertificateStatusActive,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
||||||
|
Tags: make(map[string]string),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||||
|
{ID: "cv-3", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := &domain.RenewalPolicy{
|
||||||
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||||
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||||
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||||
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
policyRepo.AddPolicy(policy)
|
||||||
|
|
||||||
|
err := svc.CheckExpiringCertificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARI is nil (not supported), so threshold-based logic applies; cert is within 30-day window
|
||||||
|
hasRenewalJob := false
|
||||||
|
for _, job := range jobRepo.Jobs {
|
||||||
|
if job.Type == domain.JobTypeRenewal {
|
||||||
|
hasRenewalJob = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRenewalJob {
|
||||||
|
t.Errorf("expected renewal job via threshold fallback when ARI returns nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
certRepo := newMockCertificateRepository()
|
||||||
|
jobRepo := newMockJobRepository()
|
||||||
|
policyRepo := newMockRenewalPolicyRepository()
|
||||||
|
auditRepo := newMockAuditRepository()
|
||||||
|
notifRepo := newMockNotificationRepository()
|
||||||
|
|
||||||
|
auditSvc := NewAuditService(auditRepo)
|
||||||
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||||
|
|
||||||
|
// ARI returns an error — should fall through to threshold-based renewal
|
||||||
|
ariConnector := &mockIssuerConnector{
|
||||||
|
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
||||||
|
}
|
||||||
|
issuerRegistry := map[string]IssuerConnector{
|
||||||
|
"iss-acme": ariConnector,
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
||||||
|
|
||||||
|
cert := &domain.ManagedCertificate{
|
||||||
|
ID: "mc-ari-err",
|
||||||
|
Name: "ARI Error Cert",
|
||||||
|
CommonName: "ari-err.example.com",
|
||||||
|
SANs: []string{},
|
||||||
|
OwnerID: "owner-1",
|
||||||
|
TeamID: "team-1",
|
||||||
|
IssuerID: "iss-acme",
|
||||||
|
RenewalPolicyID: "rp-standard",
|
||||||
|
Status: domain.CertificateStatusActive,
|
||||||
|
ExpiresAt: time.Now().AddDate(0, 0, 15),
|
||||||
|
Tags: make(map[string]string),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
certRepo.AddCert(cert)
|
||||||
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
||||||
|
{ID: "cv-4", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := &domain.RenewalPolicy{
|
||||||
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
||||||
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
||||||
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
||||||
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
policyRepo.AddPolicy(policy)
|
||||||
|
|
||||||
|
err := svc.CheckExpiringCertificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARI failed but renewal should still happen via threshold fallback
|
||||||
|
hasRenewalJob := false
|
||||||
|
for _, job := range jobRepo.Jobs {
|
||||||
|
if job.Type == domain.JobTypeRenewal {
|
||||||
|
hasRenewalJob = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRenewalJob {
|
||||||
|
t.Errorf("expected renewal job via threshold fallback when ARI errors")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stringPtr is defined in notification_test.go
|
// stringPtr is defined in notification_test.go
|
||||||
|
|||||||
@@ -243,6 +243,25 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
|
|||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.ListErr != nil {
|
||||||
|
return nil, m.ListErr
|
||||||
|
}
|
||||||
|
var result []*domain.Job
|
||||||
|
for _, j := range m.Jobs {
|
||||||
|
if j.AgentID != nil && *j.AgentID == agentID {
|
||||||
|
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||||
|
result = append(result, j)
|
||||||
|
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||||
|
result = append(result, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockJobRepo) AddJob(job *domain.Job) {
|
func (m *mockJobRepo) AddJob(job *domain.Job) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -660,8 +679,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
|
|||||||
|
|
||||||
// mockIssuerConnector is a test implementation of IssuerConnector
|
// mockIssuerConnector is a test implementation of IssuerConnector
|
||||||
type mockIssuerConnector struct {
|
type mockIssuerConnector struct {
|
||||||
Result *IssuanceResult
|
Result *IssuanceResult
|
||||||
Err error
|
Err error
|
||||||
|
getRenewalInfoResult *RenewalInfoResult
|
||||||
|
getRenewalInfoErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||||
@@ -717,14 +738,14 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
||||||
if m.Err != nil {
|
if m.getRenewalInfoErr != nil {
|
||||||
return nil, m.Err
|
return nil, m.getRenewalInfoErr
|
||||||
}
|
}
|
||||||
now := time.Now()
|
if m.getRenewalInfoResult != nil {
|
||||||
return &RenewalInfoResult{
|
return m.getRenewalInfoResult, nil
|
||||||
SuggestedWindowStart: now,
|
}
|
||||||
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
|
// Default: return nil, nil (issuer does not support ARI)
|
||||||
}, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constructor functions for mocks
|
// Constructor functions for mocks
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ func (m *mockVerificationJobRepo) GetPendingJobs(ctx context.Context, jobType do
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockVerificationJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// newVerificationTestService creates a VerificationService wired with test doubles.
|
// newVerificationTestService creates a VerificationService wired with test doubles.
|
||||||
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
|
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
|
||||||
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
|
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ import {
|
|||||||
triggerNetworkScan,
|
triggerNetworkScan,
|
||||||
previewDigest,
|
previewDigest,
|
||||||
sendDigest,
|
sendDigest,
|
||||||
|
getJob,
|
||||||
|
getJobVerification,
|
||||||
|
getIssuer,
|
||||||
|
getTarget,
|
||||||
|
getPrometheusMetrics,
|
||||||
} from './client';
|
} from './client';
|
||||||
|
|
||||||
// Mock global fetch
|
// Mock global fetch
|
||||||
@@ -1006,4 +1011,99 @@ describe('API Client', () => {
|
|||||||
expect(result.message).toBe('digest sent');
|
expect(result.message).toBe('digest sent');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Job Detail ────────────────────────────
|
||||||
|
|
||||||
|
describe('Job Detail', () => {
|
||||||
|
it('getJob fetches single job by ID', async () => {
|
||||||
|
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'job-1', type: 'Deployment', status: 'Completed' }));
|
||||||
|
const result = await getJob('job-1');
|
||||||
|
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1');
|
||||||
|
expect(result.id).toBe('job-1');
|
||||||
|
expect(result.type).toBe('Deployment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getJobVerification fetches verification result', async () => {
|
||||||
|
const verificationData = {
|
||||||
|
job_id: 'job-1',
|
||||||
|
target_id: 't-nginx1',
|
||||||
|
verified: true,
|
||||||
|
actual_fingerprint: 'abc123',
|
||||||
|
expected_fingerprint: 'abc123',
|
||||||
|
verified_at: '2026-03-28T12:00:00Z',
|
||||||
|
};
|
||||||
|
mockFetch.mockReturnValueOnce(mockJsonResponse(verificationData));
|
||||||
|
const result = await getJobVerification('job-1');
|
||||||
|
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1/verification');
|
||||||
|
expect(result.verified).toBe(true);
|
||||||
|
expect(result.actual_fingerprint).toBe('abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Issuer Detail ─────────────────────────
|
||||||
|
|
||||||
|
describe('Issuer Detail', () => {
|
||||||
|
it('getIssuer fetches single issuer by ID', async () => {
|
||||||
|
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-local', name: 'Local CA', type: 'local_ca', status: 'active' }));
|
||||||
|
const result = await getIssuer('iss-local');
|
||||||
|
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/issuers/iss-local');
|
||||||
|
expect(result.name).toBe('Local CA');
|
||||||
|
expect(result.type).toBe('local_ca');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Target Detail ─────────────────────────
|
||||||
|
|
||||||
|
describe('Target Detail', () => {
|
||||||
|
it('getTarget fetches single target by ID', async () => {
|
||||||
|
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-nginx1', name: 'Web Server', type: 'nginx', hostname: 'web1.example.com' }));
|
||||||
|
const result = await getTarget('t-nginx1');
|
||||||
|
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/targets/t-nginx1');
|
||||||
|
expect(result.name).toBe('Web Server');
|
||||||
|
expect(result.type).toBe('nginx');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Prometheus Metrics ────────────────────
|
||||||
|
|
||||||
|
describe('Prometheus Metrics', () => {
|
||||||
|
it('getPrometheusMetrics fetches text format', async () => {
|
||||||
|
const metricsText = '# HELP certctl_certificate_total Total certificates\ncertctl_certificate_total 10';
|
||||||
|
mockFetch.mockReturnValueOnce(
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve(metricsText),
|
||||||
|
} as Response)
|
||||||
|
);
|
||||||
|
const result = await getPrometheusMetrics();
|
||||||
|
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics/prometheus');
|
||||||
|
expect(result).toContain('certctl_certificate_total');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPrometheusMetrics throws on error', async () => {
|
||||||
|
mockFetch.mockReturnValueOnce(
|
||||||
|
Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: () => Promise.resolve('error'),
|
||||||
|
} as Response)
|
||||||
|
);
|
||||||
|
await expect(getPrometheusMetrics()).rejects.toThrow('Prometheus metrics failed: 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPrometheusMetrics includes auth header', async () => {
|
||||||
|
setApiKey('prom-key');
|
||||||
|
mockFetch.mockReturnValueOnce(
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve('metrics'),
|
||||||
|
} as Response)
|
||||||
|
);
|
||||||
|
await getPrometheusMetrics();
|
||||||
|
const [, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(init.headers['Authorization']).toBe('Bearer prom-key');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -365,5 +365,32 @@ export const previewDigest = () => {
|
|||||||
export const sendDigest = () =>
|
export const sendDigest = () =>
|
||||||
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
|
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
|
||||||
|
|
||||||
|
// Jobs (single)
|
||||||
|
export const getJob = (id: string) =>
|
||||||
|
fetchJSON<Job>(`${BASE}/jobs/${id}`);
|
||||||
|
|
||||||
|
// Job Verification
|
||||||
|
export const getJobVerification = (id: string) =>
|
||||||
|
fetchJSON<{ job_id: string; target_id: string; verified: boolean; actual_fingerprint: string; expected_fingerprint: string; verified_at: string; error?: string }>(`${BASE}/jobs/${id}/verification`);
|
||||||
|
|
||||||
|
// Issuers (single)
|
||||||
|
export const getIssuer = (id: string) =>
|
||||||
|
fetchJSON<Issuer>(`${BASE}/issuers/${id}`);
|
||||||
|
|
||||||
|
// Targets (single)
|
||||||
|
export const getTarget = (id: string) =>
|
||||||
|
fetchJSON<Target>(`${BASE}/targets/${id}`);
|
||||||
|
|
||||||
|
// Prometheus metrics (text format)
|
||||||
|
export const getPrometheusMetrics = () => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
return fetch(`${BASE}/metrics/prometheus`, { headers })
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(`Prometheus metrics failed: ${r.status}`);
|
||||||
|
return r.text();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface Job {
|
|||||||
id: string;
|
id: string;
|
||||||
certificate_id: string;
|
certificate_id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
target_id?: string;
|
||||||
|
agent_id?: string;
|
||||||
status: string;
|
status: string;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
max_attempts: number;
|
max_attempts: number;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const nav = [
|
|||||||
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
|
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
|
||||||
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
|
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
|
||||||
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||||
|
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
||||||
|
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import ShortLivedPage from './pages/ShortLivedPage';
|
|||||||
import AgentFleetPage from './pages/AgentFleetPage';
|
import AgentFleetPage from './pages/AgentFleetPage';
|
||||||
import DiscoveryPage from './pages/DiscoveryPage';
|
import DiscoveryPage from './pages/DiscoveryPage';
|
||||||
import NetworkScanPage from './pages/NetworkScanPage';
|
import NetworkScanPage from './pages/NetworkScanPage';
|
||||||
|
import DigestPage from './pages/DigestPage';
|
||||||
|
import ObservabilityPage from './pages/ObservabilityPage';
|
||||||
|
import JobDetailPage from './pages/JobDetailPage';
|
||||||
|
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||||
|
import TargetDetailPage from './pages/TargetDetailPage';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||||
<Route path="fleet" element={<AgentFleetPage />} />
|
<Route path="fleet" element={<AgentFleetPage />} />
|
||||||
<Route path="jobs" element={<JobsPage />} />
|
<Route path="jobs" element={<JobsPage />} />
|
||||||
|
<Route path="jobs/:id" element={<JobDetailPage />} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
<Route path="policies" element={<PoliciesPage />} />
|
<Route path="policies" element={<PoliciesPage />} />
|
||||||
<Route path="profiles" element={<ProfilesPage />} />
|
<Route path="profiles" element={<ProfilesPage />} />
|
||||||
<Route path="issuers" element={<IssuersPage />} />
|
<Route path="issuers" element={<IssuersPage />} />
|
||||||
|
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||||
<Route path="targets" element={<TargetsPage />} />
|
<Route path="targets" element={<TargetsPage />} />
|
||||||
|
<Route path="targets/:id" element={<TargetDetailPage />} />
|
||||||
<Route path="owners" element={<OwnersPage />} />
|
<Route path="owners" element={<OwnersPage />} />
|
||||||
<Route path="teams" element={<TeamsPage />} />
|
<Route path="teams" element={<TeamsPage />} />
|
||||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
||||||
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="short-lived" element={<ShortLivedPage />} />
|
<Route path="short-lived" element={<ShortLivedPage />} />
|
||||||
<Route path="discovery" element={<DiscoveryPage />} />
|
<Route path="discovery" element={<DiscoveryPage />} />
|
||||||
<Route path="network-scans" element={<NetworkScanPage />} />
|
<Route path="network-scans" element={<NetworkScanPage />} />
|
||||||
|
<Route path="digest" element={<DigestPage />} />
|
||||||
|
<Route path="observability" element={<ObservabilityPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { previewDigest, sendDigest } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
|
||||||
|
export default function DigestPage() {
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const { data: html, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['digest-preview'],
|
||||||
|
queryFn: previewDigest,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: sendDigest,
|
||||||
|
onSuccess: () => setShowConfirm(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Certificate Digest"
|
||||||
|
subtitle="Preview and send the scheduled certificate digest email"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
disabled={!html || sendMutation.isPending}
|
||||||
|
className="btn btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Send Digest Now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{sendMutation.isSuccess && (
|
||||||
|
<div className="mb-4 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
|
||||||
|
Digest sent successfully.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sendMutation.isError && (
|
||||||
|
<div className="mb-4 px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
Failed to send digest: {(sendMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-sm text-ink-muted">Loading digest preview...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<ErrorState
|
||||||
|
error={error as Error}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{html && (
|
||||||
|
<div className="bg-white border border-surface-border rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<div className="px-4 py-2.5 bg-surface border-b border-surface-border flex items-center justify-between">
|
||||||
|
<span className="text-xs text-ink-muted font-medium">Email Preview</span>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="text-xs text-brand-400 hover:text-brand-500"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
srcDoc={html}
|
||||||
|
title="Digest Preview"
|
||||||
|
className="w-full border-0"
|
||||||
|
style={{ minHeight: '600px' }}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="px-6 py-4 border-b border-surface-border">
|
||||||
|
<h3 className="text-lg font-semibold text-ink">Send Digest</h3>
|
||||||
|
<p className="text-sm text-ink-muted mt-1">
|
||||||
|
This will send the certificate digest email to all configured recipients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
|
||||||
|
<button onClick={() => setShowConfirm(false)} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm text-white bg-brand-500 hover:bg-brand-600 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? 'Sending...' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDateTime } from '../api/utils';
|
||||||
|
import type { Certificate } from '../api/types';
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
local_ca: 'Local CA',
|
||||||
|
acme: 'ACME (Let\'s Encrypt)',
|
||||||
|
step_ca: 'step-ca',
|
||||||
|
openssl: 'OpenSSL / Custom',
|
||||||
|
vault: 'Vault PKI',
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||||
|
<span className="text-sm text-ink-muted">{label}</span>
|
||||||
|
<span className="text-sm text-ink">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IssuerDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data: issuer, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['issuer', id],
|
||||||
|
queryFn: () => getIssuer(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: certsData } = useQuery({
|
||||||
|
queryKey: ['certificates', { issuer_id: id }],
|
||||||
|
queryFn: () => getCertificates({ issuer_id: id! }),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: () => testIssuerConnection(id!),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Issuer Details" />
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !issuer) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Issuer Details" />
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-sm text-ink-muted">Loading issuer...</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redact sensitive config fields
|
||||||
|
const safeConfig = issuer.config ? Object.fromEntries(
|
||||||
|
Object.entries(issuer.config).map(([k, v]) => {
|
||||||
|
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
|
||||||
|
return [k, sensitive ? '********' : v];
|
||||||
|
})
|
||||||
|
) : {};
|
||||||
|
|
||||||
|
const certColumns: Column<Certificate>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Certificate',
|
||||||
|
render: (c) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-ink text-sm">{c.common_name}</div>
|
||||||
|
<div className="text-xs text-ink-faint font-mono">{c.id}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
|
||||||
|
{ key: 'expires', label: 'Expires', render: (c) => <span className="text-xs text-ink-muted">{formatDateTime(c.expires_at)}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={issuer.name}
|
||||||
|
subtitle={typeLabels[issuer.type] || issuer.type}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => testMutation.mutate()}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
className="btn btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
|
{testMutation.isSuccess && (
|
||||||
|
<div className="px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
|
||||||
|
Connection test passed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testMutation.isError && (
|
||||||
|
<div className="px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
Connection test failed: {(testMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Issuer info */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Issuer Information</h3>
|
||||||
|
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
|
||||||
|
<InfoRow label="Name" value={issuer.name} />
|
||||||
|
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
|
||||||
|
<InfoRow label="Status" value={<StatusBadge status={issuer.status} />} />
|
||||||
|
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config (redacted) */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
|
||||||
|
{Object.keys(safeConfig).length > 0 ? (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{Object.entries(safeConfig).map(([key, val]) => (
|
||||||
|
<InfoRow key={key} label={key} value={
|
||||||
|
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
|
||||||
|
} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issued certificates */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||||
|
Issued Certificates {certsData ? `(${certsData.total})` : ''}
|
||||||
|
</h3>
|
||||||
|
<DataTable
|
||||||
|
columns={certColumns}
|
||||||
|
data={certsData?.data || []}
|
||||||
|
isLoading={!certsData}
|
||||||
|
emptyMessage="No certificates issued by this issuer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -120,7 +121,9 @@ export default function IssuersPage() {
|
|||||||
label: 'Issuer',
|
label: 'Issuer',
|
||||||
render: (i) => (
|
render: (i) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-ink">{i.name}</div>
|
<Link to={`/issuers/${i.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{i.name}
|
||||||
|
</Link>
|
||||||
<div className="text-xs text-ink-faint font-mono">{i.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{i.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDateTime, timeAgo } from '../api/utils';
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||||
|
<span className="text-sm text-ink-muted">{label}</span>
|
||||||
|
<span className="text-sm text-ink">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerificationBadge({ status }: { status?: string }) {
|
||||||
|
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
success: 'bg-emerald-100 text-emerald-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
skipped: 'bg-gray-100 text-gray-600',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
success: 'Verified',
|
||||||
|
failed: 'Failed',
|
||||||
|
pending: 'Pending',
|
||||||
|
skipped: 'Skipped',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data: job, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['job', id],
|
||||||
|
queryFn: () => getJob(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: verification } = useQuery({
|
||||||
|
queryKey: ['job-verification', id],
|
||||||
|
queryFn: () => getJobVerification(id!),
|
||||||
|
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: auditData } = useQuery({
|
||||||
|
queryKey: ['audit', { resource_id: id }],
|
||||||
|
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Job Details" />
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !job) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Job Details" />
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-sm text-ink-muted">Loading job...</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={`Job ${job.id}`}
|
||||||
|
subtitle={`${job.type} job`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Job details */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
|
||||||
|
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
|
||||||
|
<InfoRow label="Type" value={job.type} />
|
||||||
|
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
|
||||||
|
<InfoRow label="Certificate" value={
|
||||||
|
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||||
|
{job.certificate_id}
|
||||||
|
</Link>
|
||||||
|
} />
|
||||||
|
{job.agent_id && (
|
||||||
|
<InfoRow label="Agent" value={
|
||||||
|
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||||
|
{job.agent_id}
|
||||||
|
</Link>
|
||||||
|
} />
|
||||||
|
)}
|
||||||
|
{job.target_id && (
|
||||||
|
<InfoRow label="Target" value={
|
||||||
|
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||||
|
{job.target_id}
|
||||||
|
</Link>
|
||||||
|
} />
|
||||||
|
)}
|
||||||
|
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
|
||||||
|
{job.error_message && (
|
||||||
|
<InfoRow label="Error" value={
|
||||||
|
<span className="text-red-600 text-xs">{job.error_message}</span>
|
||||||
|
} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
|
||||||
|
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
|
||||||
|
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
|
||||||
|
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
|
||||||
|
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
|
||||||
|
{job.completed_at && job.started_at && (
|
||||||
|
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verification section — only for deployment jobs */}
|
||||||
|
{job.type === 'Deployment' && (
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
|
||||||
|
{job.verification_status ? (
|
||||||
|
<div className="space-y-0">
|
||||||
|
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
|
||||||
|
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
|
||||||
|
{job.verification_fingerprint && (
|
||||||
|
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
|
||||||
|
)}
|
||||||
|
{job.verification_error && (
|
||||||
|
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
|
||||||
|
)}
|
||||||
|
{verification && verification.verified && (
|
||||||
|
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-ink-faint py-4 text-center">
|
||||||
|
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit trail */}
|
||||||
|
{auditData && auditData.data.length > 0 && (
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{auditData.data.map(event => (
|
||||||
|
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-ink">{event.action}</span>
|
||||||
|
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -47,6 +48,27 @@ function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VerificationBadge({ status }: { status?: string }) {
|
||||||
|
if (!status) return <span className="text-xs text-ink-faint">—</span>;
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
success: 'bg-emerald-100 text-emerald-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
skipped: 'bg-gray-100 text-gray-600',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
success: 'Verified',
|
||||||
|
failed: 'Failed',
|
||||||
|
pending: 'Pending',
|
||||||
|
skipped: 'Skipped',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function JobsPage() {
|
export default function JobsPage() {
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState('');
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
@@ -89,13 +111,26 @@ export default function JobsPage() {
|
|||||||
label: 'Job',
|
label: 'Job',
|
||||||
render: (j) => (
|
render: (j) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono text-xs text-ink">{j.id}</div>
|
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{j.id}
|
||||||
|
</Link>
|
||||||
<div className="text-xs text-ink-faint">{j.type}</div>
|
<div className="text-xs text-ink-faint">{j.type}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||||
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
|
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
|
||||||
|
{
|
||||||
|
key: 'agent',
|
||||||
|
label: 'Agent',
|
||||||
|
render: (j) => j.agent_id ? (
|
||||||
|
<Link to={`/agents/${j.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{j.agent_id}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink-faint">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'attempts',
|
key: 'attempts',
|
||||||
label: 'Attempts',
|
label: 'Attempts',
|
||||||
@@ -103,6 +138,11 @@ export default function JobsPage() {
|
|||||||
},
|
},
|
||||||
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
|
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
|
||||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||||
|
{
|
||||||
|
key: 'verification',
|
||||||
|
label: 'Verification',
|
||||||
|
render: (j) => j.type === 'Deployment' ? <VerificationBadge status={j.verification_status} /> : <span className="text-xs text-ink-faint">—</span>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
|
||||||
|
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||||
|
<div className="text-xs text-ink-muted mb-1">{label}</div>
|
||||||
|
<div className="text-2xl font-bold text-ink">{value}</div>
|
||||||
|
{sub && <div className="text-xs text-ink-faint mt-1">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ObservabilityPage() {
|
||||||
|
const { data: metrics, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['metrics'],
|
||||||
|
queryFn: getMetrics,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: health } = useQuery({
|
||||||
|
queryKey: ['health'],
|
||||||
|
queryFn: getHealth,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: promText } = useQuery({
|
||||||
|
queryKey: ['prometheus-metrics'],
|
||||||
|
queryFn: getPrometheusMetrics,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Observability" />
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Observability"
|
||||||
|
subtitle={health ? `Server: ${health.status}` : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
|
{/* Health status */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${health?.status === 'ok' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||||
|
<span className="text-sm text-ink font-medium">
|
||||||
|
Server {health?.status === 'ok' ? 'Healthy' : 'Unhealthy'}
|
||||||
|
</span>
|
||||||
|
{metrics && (
|
||||||
|
<span className="text-xs text-ink-faint ml-auto">
|
||||||
|
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gauge metrics */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-ink-muted py-10 text-center">Loading metrics...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-3">Certificate Gauges</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
<MetricCard label="Total" value={metrics.gauge.certificate_total} />
|
||||||
|
<MetricCard label="Active" value={metrics.gauge.certificate_active} />
|
||||||
|
<MetricCard label="Expiring Soon" value={metrics.gauge.certificate_expiring_soon} />
|
||||||
|
<MetricCard label="Expired" value={metrics.gauge.certificate_expired} />
|
||||||
|
<MetricCard label="Revoked" value={metrics.gauge.certificate_revoked} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-3">Agent & Job Gauges</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<MetricCard label="Total Agents" value={metrics.gauge.agent_total} />
|
||||||
|
<MetricCard label="Online Agents" value={metrics.gauge.agent_online} />
|
||||||
|
<MetricCard label="Pending Jobs" value={metrics.gauge.job_pending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-3">Counters</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||||
|
<MetricCard label="Jobs Completed (total)" value={metrics.counter.job_completed_total} />
|
||||||
|
<MetricCard label="Jobs Failed (total)" value={metrics.counter.job_failed_total} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prometheus config */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-3">Prometheus Integration</h3>
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
|
||||||
|
<p className="text-sm text-ink mb-3">
|
||||||
|
Add this scrape target to your <code className="text-xs bg-surface-muted px-1 py-0.5 rounded">prometheus.yml</code>:
|
||||||
|
</p>
|
||||||
|
<pre className="bg-ink text-white rounded p-4 text-xs overflow-x-auto font-mono">
|
||||||
|
{`scrape_configs:
|
||||||
|
- job_name: 'certctl'
|
||||||
|
metrics_path: '/api/v1/metrics/prometheus'
|
||||||
|
scheme: 'https'
|
||||||
|
bearer_token: '<YOUR_API_KEY>'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['<CERTCTL_HOST>:443']`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Prometheus output */}
|
||||||
|
{promText && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-3">Live Prometheus Output</h3>
|
||||||
|
<div className="bg-surface border border-surface-border rounded shadow-sm">
|
||||||
|
<div className="px-4 py-2 border-b border-surface-border flex items-center justify-between">
|
||||||
|
<span className="text-xs text-ink-faint font-mono">GET /api/v1/metrics/prometheus</span>
|
||||||
|
<span className="text-xs text-ink-faint">text/plain</span>
|
||||||
|
</div>
|
||||||
|
<pre className="p-4 text-xs text-ink-muted overflow-x-auto font-mono max-h-96 overflow-y-auto whitespace-pre">
|
||||||
|
{promText}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getTarget, getJobs } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDateTime } from '../api/utils';
|
||||||
|
import type { Job } from '../api/types';
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
nginx: 'NGINX',
|
||||||
|
apache: 'Apache',
|
||||||
|
haproxy: 'HAProxy',
|
||||||
|
traefik: 'Traefik',
|
||||||
|
caddy: 'Caddy',
|
||||||
|
f5_bigip: 'F5 BIG-IP',
|
||||||
|
iis: 'IIS',
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between py-2 border-b border-surface-border/50">
|
||||||
|
<span className="text-sm text-ink-muted">{label}</span>
|
||||||
|
<span className="text-sm text-ink">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TargetDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data: target, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['target', id],
|
||||||
|
queryFn: () => getTarget(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deployment jobs for this target
|
||||||
|
const { data: jobsData } = useQuery({
|
||||||
|
queryKey: ['jobs', { target_id: id, type: 'Deployment' }],
|
||||||
|
queryFn: () => getJobs({ target_id: id! }),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Target Details" />
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !target) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Target Details" />
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-sm text-ink-muted">Loading target...</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobColumns: Column<Job>[] = [
|
||||||
|
{
|
||||||
|
key: 'id',
|
||||||
|
label: 'Job',
|
||||||
|
render: (j) => (
|
||||||
|
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright">
|
||||||
|
{j.id}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||||
|
{ key: 'cert', label: 'Certificate', render: (j) => (
|
||||||
|
<Link to={`/certificates/${j.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||||
|
{j.certificate_id}
|
||||||
|
</Link>
|
||||||
|
)},
|
||||||
|
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||||
|
{
|
||||||
|
key: 'verification',
|
||||||
|
label: 'Verification',
|
||||||
|
render: (j) => {
|
||||||
|
if (!j.verification_status) return <span className="text-xs text-ink-faint">—</span>;
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
success: 'bg-emerald-100 text-emerald-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
skipped: 'bg-gray-100 text-gray-600',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
success: 'Verified',
|
||||||
|
failed: 'Failed',
|
||||||
|
pending: 'Pending',
|
||||||
|
skipped: 'Skipped',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[j.verification_status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{labels[j.verification_status] || j.verification_status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={target.name}
|
||||||
|
subtitle={typeLabels[target.type] || target.type}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Target info */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Target Information</h3>
|
||||||
|
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
|
||||||
|
<InfoRow label="Name" value={target.name} />
|
||||||
|
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
|
||||||
|
<InfoRow label="Hostname" value={target.hostname || '—'} />
|
||||||
|
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
|
||||||
|
{target.agent_id && (
|
||||||
|
<InfoRow label="Agent" value={
|
||||||
|
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||||
|
{target.agent_id}
|
||||||
|
</Link>
|
||||||
|
} />
|
||||||
|
)}
|
||||||
|
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
|
||||||
|
{target.config && Object.keys(target.config).length > 0 ? (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{Object.entries(target.config).map(([key, val]) => (
|
||||||
|
<InfoRow key={key} label={key.replace(/_/g, ' ')} value={
|
||||||
|
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
|
||||||
|
} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployment history */}
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">
|
||||||
|
Deployment History {jobsData ? `(${jobsData.total})` : ''}
|
||||||
|
</h3>
|
||||||
|
<DataTable
|
||||||
|
columns={jobColumns}
|
||||||
|
data={jobsData?.data || []}
|
||||||
|
isLoading={!jobsData}
|
||||||
|
emptyMessage="No deployments to this target"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getTargets, createTarget, deleteTarget } from '../api/client';
|
import { getTargets, createTarget, deleteTarget } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -266,7 +267,9 @@ export default function TargetsPage() {
|
|||||||
label: 'Target',
|
label: 'Target',
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-ink">{t.name}</div>
|
<Link to={`/targets/${t.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{t.name}
|
||||||
|
</Link>
|
||||||
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
|
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user