mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 02:18:57 +00:00
Initial scaffold: certificate control plane v0.1.0
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// AgentService provides business logic for managing and coordinating with agents.
|
||||
type AgentService struct {
|
||||
agentRepo repository.AgentRepository
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
auditService *AuditService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
}
|
||||
|
||||
// NewAgentService creates a new agent service.
|
||||
func NewAgentService(
|
||||
agentRepo repository.AgentRepository,
|
||||
certRepo repository.CertificateRepository,
|
||||
jobRepo repository.JobRepository,
|
||||
auditService *AuditService,
|
||||
issuerRegistry map[string]IssuerConnector,
|
||||
) *AgentService {
|
||||
return &AgentService{
|
||||
agentRepo: agentRepo,
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
auditService: auditService,
|
||||
issuerRegistry: issuerRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
// Register creates a new agent and returns its API key (only once).
|
||||
func (s *AgentService) Register(ctx context.Context, name string, hostname string) (*domain.Agent, string, error) {
|
||||
if name == "" || hostname == "" {
|
||||
return nil, "", fmt.Errorf("agent name and hostname are required")
|
||||
}
|
||||
|
||||
// Generate API key
|
||||
apiKey := generateAPIKey()
|
||||
apiKeyHash := hashAPIKey(apiKey)
|
||||
|
||||
now := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: generateID("agent"),
|
||||
Name: name,
|
||||
Hostname: hostname,
|
||||
APIKeyHash: apiKeyHash,
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
}
|
||||
|
||||
if err := s.agentRepo.Create(ctx, agent); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create agent: %w", err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"agent_registered", "agent", agent.ID,
|
||||
map[string]interface{}{"name": name, "hostname": hostname}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
// Return the API key only once; the agent must save it securely
|
||||
return agent, apiKey, nil
|
||||
}
|
||||
|
||||
// Heartbeat updates an agent's last seen time and status.
|
||||
func (s *AgentService) Heartbeat(ctx context.Context, agentID string) error {
|
||||
agent, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Update heartbeat
|
||||
if err := s.agentRepo.UpdateHeartbeat(ctx, agentID); err != nil {
|
||||
return fmt.Errorf("failed to update heartbeat: %w", err)
|
||||
}
|
||||
|
||||
// Update status if previously offline
|
||||
if agent.Status != domain.AgentStatusOnline {
|
||||
agent.Status = domain.AgentStatusOnline
|
||||
if err := s.agentRepo.Update(ctx, agent); err != nil {
|
||||
fmt.Printf("failed to update agent status: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubmitCSR validates and processes a Certificate Signing Request from an agent.
|
||||
func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID string, csrPEM []byte) error {
|
||||
// Fetch agent
|
||||
agent, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Validate CSR format (basic check)
|
||||
if len(csrPEM) == 0 {
|
||||
return fmt.Errorf("invalid CSR: empty")
|
||||
}
|
||||
|
||||
// In production, parse and validate the CSR signature and CN here
|
||||
// For now, accept and proceed
|
||||
|
||||
// In a production system, we'd store the CSR in a certificate version or metadata
|
||||
// For now, we just validate and accept it
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, agent.ID, domain.ActorTypeAgent,
|
||||
"csr_submitted", "certificate", certID,
|
||||
map[string]interface{}{"agent_hostname": agent.Hostname}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCertificateForAgent returns the latest public certificate material for an agent.
|
||||
func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID string, certID string) ([]byte, error) {
|
||||
// Fetch agent
|
||||
_, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Get latest version
|
||||
versions, err := s.certRepo.ListVersions(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch certificate versions: %w", err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return nil, fmt.Errorf("no certificate versions found")
|
||||
}
|
||||
|
||||
// Return the most recent version (latest CreatedAt timestamp)
|
||||
latestVersion := versions[0]
|
||||
for _, v := range versions {
|
||||
if v.CreatedAt.After(latestVersion.CreatedAt) {
|
||||
latestVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, agentID, domain.ActorTypeAgent,
|
||||
"certificate_retrieved", "certificate", certID,
|
||||
map[string]interface{}{"version": latestVersion.SerialNumber}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return []byte(latestVersion.PEMChain), nil
|
||||
}
|
||||
|
||||
// GetPendingWork returns deployment jobs assigned to an agent.
|
||||
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
// Fetch agent to verify it exists
|
||||
_, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Get all deployment jobs
|
||||
jobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
|
||||
}
|
||||
|
||||
var workForAgent []*domain.Job
|
||||
|
||||
// Filter to only jobs assigned to this agent
|
||||
// Note: In this implementation, agents don't filter jobs by assignment
|
||||
// All deployment jobs are returned for the agent to process
|
||||
for _, job := range jobs {
|
||||
if job.Type == domain.JobTypeDeployment {
|
||||
workForAgent = append(workForAgent, job)
|
||||
}
|
||||
}
|
||||
|
||||
return workForAgent, nil
|
||||
}
|
||||
|
||||
// ReportJobStatus updates a job's status based on agent feedback.
|
||||
func (s *AgentService) ReportJobStatus(ctx context.Context, agentID string, jobID string, status domain.JobStatus, errMsg string) error {
|
||||
// Fetch job to verify it exists
|
||||
_, err := s.jobRepo.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch job: %w", err)
|
||||
}
|
||||
|
||||
// Update job status
|
||||
if err := s.jobRepo.UpdateStatus(ctx, jobID, status, errMsg); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, agentID, domain.ActorTypeAgent,
|
||||
"job_status_reported", "job", jobID,
|
||||
map[string]interface{}{"status": status, "error": errMsg}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkStaleAgentsOffline marks agents as offline if they haven't sent a heartbeat
|
||||
// within the given threshold duration.
|
||||
func (s *AgentService) MarkStaleAgentsOffline(ctx context.Context, threshold time.Duration) error {
|
||||
agents, err := s.agentRepo.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list agents: %w", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-threshold)
|
||||
for _, agent := range agents {
|
||||
if agent.Status == domain.AgentStatusOnline && agent.LastHeartbeatAt != nil && agent.LastHeartbeatAt.Before(cutoff) {
|
||||
agent.Status = domain.AgentStatusOffline
|
||||
if err := s.agentRepo.Update(ctx, agent); err != nil {
|
||||
fmt.Printf("failed to mark agent %s offline: %v\n", agent.ID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentByAPIKey retrieves an agent by hashed API key.
|
||||
func (s *AgentService) GetAgentByAPIKey(ctx context.Context, apiKey string) (*domain.Agent, error) {
|
||||
apiKeyHash := hashAPIKey(apiKey)
|
||||
agent, err := s.agentRepo.GetByAPIKey(ctx, apiKeyHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid API key: %w", err)
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// generateAPIKey creates a random API key for an agent.
|
||||
func generateAPIKey() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, 32)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// hashAPIKey hashes an API key using SHA256.
|
||||
func hashAPIKey(apiKey string) string {
|
||||
hash := sha256.Sum256([]byte(apiKey))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// AuditService provides business logic for recording and retrieving audit events.
|
||||
type AuditService struct {
|
||||
auditRepo repository.AuditRepository
|
||||
}
|
||||
|
||||
// NewAuditService creates a new audit service.
|
||||
func NewAuditService(auditRepo repository.AuditRepository) *AuditService {
|
||||
return &AuditService{
|
||||
auditRepo: auditRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// RecordEvent records an audit event with actor, action, and resource information.
|
||||
func (s *AuditService) RecordEvent(ctx context.Context, actor string, actorType domain.ActorType, action string, resourceType string, resourceID string, details map[string]interface{}) error {
|
||||
detailsJSON, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
detailsJSON = []byte("{}")
|
||||
}
|
||||
|
||||
event := &domain.AuditEvent{
|
||||
ID: generateID("audit"),
|
||||
Timestamp: time.Now(),
|
||||
Actor: actor,
|
||||
ActorType: actorType,
|
||||
Action: action,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
Details: json.RawMessage(detailsJSON),
|
||||
}
|
||||
|
||||
if err := s.auditRepo.Create(ctx, event); err != nil {
|
||||
return fmt.Errorf("failed to record audit event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns audit events matching filter criteria.
|
||||
func (s *AuditService) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
|
||||
events, err := s.auditRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ListByResource returns all audit events for a specific resource.
|
||||
func (s *AuditService) ListByResource(ctx context.Context, resourceType string, resourceID string) ([]*domain.AuditEvent, error) {
|
||||
filter := &repository.AuditFilter{
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
PerPage: 1000, // reasonable default for single resource
|
||||
}
|
||||
|
||||
events, err := s.auditRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ListByActor returns all audit events for a specific actor.
|
||||
func (s *AuditService) ListByActor(ctx context.Context, actor string) ([]*domain.AuditEvent, error) {
|
||||
filter := &repository.AuditFilter{
|
||||
Actor: actor,
|
||||
PerPage: 1000,
|
||||
}
|
||||
|
||||
events, err := s.auditRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// ListByAction returns all audit events for a specific action type.
|
||||
func (s *AuditService) ListByAction(ctx context.Context, action string, from, to time.Time) ([]*domain.AuditEvent, error) {
|
||||
filter := &repository.AuditFilter{
|
||||
From: from,
|
||||
To: to,
|
||||
PerPage: 1000,
|
||||
}
|
||||
|
||||
events, err := s.auditRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audit events: %w", err)
|
||||
}
|
||||
|
||||
// Filter by action on client side (repository may not filter by action directly)
|
||||
var filtered []*domain.AuditEvent
|
||||
for _, e := range events {
|
||||
if e.Action == action {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// CertificateService provides business logic for certificate management.
|
||||
type CertificateService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
policyService *PolicyService
|
||||
auditService *AuditService
|
||||
}
|
||||
|
||||
// NewCertificateService creates a new certificate service.
|
||||
func NewCertificateService(
|
||||
certRepo repository.CertificateRepository,
|
||||
policyService *PolicyService,
|
||||
auditService *AuditService,
|
||||
) *CertificateService {
|
||||
return &CertificateService{
|
||||
certRepo: certRepo,
|
||||
policyService: policyService,
|
||||
auditService: auditService,
|
||||
}
|
||||
}
|
||||
|
||||
// List returns a paginated list of certificates matching the filter.
|
||||
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
||||
certs, total, err := s.certRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list certificates: %w", err)
|
||||
}
|
||||
return certs, total, nil
|
||||
}
|
||||
|
||||
// Get retrieves a certificate by ID.
|
||||
func (s *CertificateService) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
|
||||
cert, err := s.certRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get certificate %s: %w", id, err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// Create validates and stores a new certificate.
|
||||
func (s *CertificateService) Create(ctx context.Context, cert *domain.ManagedCertificate, actor string) error {
|
||||
// Validate certificate structure
|
||||
if cert.ID == "" || cert.CommonName == "" || cert.IssuerID == "" {
|
||||
return fmt.Errorf("invalid certificate: missing required fields")
|
||||
}
|
||||
|
||||
// Run policy validation
|
||||
violations, err := s.policyService.ValidateCertificate(ctx, cert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("policy validation failed: %w", err)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
// Record violations but do not block creation
|
||||
for _, v := range violations {
|
||||
_ = s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"policy_violation_detected", "certificate", cert.ID,
|
||||
map[string]interface{}{"rule_id": v.RuleID, "message": v.Message})
|
||||
}
|
||||
}
|
||||
|
||||
// Store certificate
|
||||
if err := s.certRepo.Create(ctx, cert); err != nil {
|
||||
return fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"certificate_created", "certificate", cert.ID,
|
||||
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
|
||||
// Log but don't fail the operation
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing certificate.
|
||||
func (s *CertificateService) Update(ctx context.Context, cert *domain.ManagedCertificate, actor string) error {
|
||||
existing, err := s.certRepo.Get(ctx, cert.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch existing certificate: %w", err)
|
||||
}
|
||||
|
||||
// Run policy validation on updated cert
|
||||
violations, err := s.policyService.ValidateCertificate(ctx, cert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("policy validation failed: %w", err)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
for _, v := range violations {
|
||||
_ = s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"policy_violation_detected", "certificate", cert.ID,
|
||||
map[string]interface{}{"rule_id": v.RuleID, "message": v.Message})
|
||||
}
|
||||
}
|
||||
|
||||
// Store updated certificate
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
return fmt.Errorf("failed to update certificate: %w", err)
|
||||
}
|
||||
|
||||
// Record audit event with diff info
|
||||
changes := map[string]interface{}{}
|
||||
if existing.Status != cert.Status {
|
||||
changes["status"] = fmt.Sprintf("%s -> %s", existing.Status, cert.Status)
|
||||
}
|
||||
if existing.ExpiresAt != cert.ExpiresAt {
|
||||
changes["expiry"] = fmt.Sprintf("%s -> %s", existing.ExpiresAt, cert.ExpiresAt)
|
||||
}
|
||||
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"certificate_updated", "certificate", cert.ID, changes); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Archive marks a certificate as archived.
|
||||
func (s *CertificateService) Archive(ctx context.Context, id string, actor string) error {
|
||||
cert, err := s.certRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
if err := s.certRepo.Archive(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to archive certificate: %w", err)
|
||||
}
|
||||
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"certificate_archived", "certificate", id,
|
||||
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVersions returns all versions of a certificate.
|
||||
func (s *CertificateService) GetVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
|
||||
versions, err := s.certRepo.ListVersions(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list certificate versions: %w", err)
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// TriggerRenewal initiates a renewal job if the certificate is eligible.
|
||||
func (s *CertificateService) TriggerRenewal(ctx context.Context, certID string, actor string) error {
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
// Validate eligibility
|
||||
if cert.Status == domain.CertificateStatusArchived {
|
||||
return fmt.Errorf("cannot renew archived certificate")
|
||||
}
|
||||
if cert.Status == domain.CertificateStatusExpired {
|
||||
return fmt.Errorf("cannot renew expired certificate; reissue instead")
|
||||
}
|
||||
|
||||
// Check if already renewing
|
||||
if cert.Status == domain.CertificateStatusRenewalInProgress {
|
||||
return fmt.Errorf("certificate renewal already in progress")
|
||||
}
|
||||
|
||||
// Update status
|
||||
cert.Status = domain.CertificateStatusRenewalInProgress
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
return fmt.Errorf("failed to update certificate status: %w", err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"renewal_triggered", "certificate", certID,
|
||||
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerDeployment creates deployment jobs for all targets of a certificate.
|
||||
func (s *CertificateService) TriggerDeployment(ctx context.Context, certID string, actor string) error {
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
if cert.Status == domain.CertificateStatusArchived {
|
||||
return fmt.Errorf("cannot deploy archived certificate")
|
||||
}
|
||||
|
||||
// Note: In practice, the DeploymentService would be called to create jobs.
|
||||
// This is a placeholder for the coordination logic.
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"deployment_triggered", "certificate", certID,
|
||||
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// DeploymentService manages certificate deployment to targets via agents.
|
||||
type DeploymentService struct {
|
||||
jobRepo repository.JobRepository
|
||||
targetRepo repository.TargetRepository
|
||||
agentRepo repository.AgentRepository
|
||||
certRepo repository.CertificateRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
}
|
||||
|
||||
// NewDeploymentService creates a new deployment service.
|
||||
func NewDeploymentService(
|
||||
jobRepo repository.JobRepository,
|
||||
targetRepo repository.TargetRepository,
|
||||
agentRepo repository.AgentRepository,
|
||||
certRepo repository.CertificateRepository,
|
||||
auditService *AuditService,
|
||||
notificationSvc *NotificationService,
|
||||
) *DeploymentService {
|
||||
return &DeploymentService{
|
||||
jobRepo: jobRepo,
|
||||
targetRepo: targetRepo,
|
||||
agentRepo: agentRepo,
|
||||
certRepo: certRepo,
|
||||
auditService: auditService,
|
||||
notificationSvc: notificationSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDeploymentJobs creates a job for each target of a certificate.
|
||||
func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID string) ([]string, error) {
|
||||
// Fetch all targets for this certificate
|
||||
targets, err := s.targetRepo.ListByCertificate(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list targets: %w", err)
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("no targets found for certificate %s", certID)
|
||||
}
|
||||
|
||||
var jobIDs []string
|
||||
|
||||
// Create a deployment job for each target
|
||||
for _, target := range targets {
|
||||
job := &domain.Job{
|
||||
ID: generateID("job"),
|
||||
CertificateID: certID,
|
||||
Type: domain.JobTypeDeployment,
|
||||
Status: domain.JobStatusPending,
|
||||
ScheduledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
// Store target info in TargetID field
|
||||
if target.ID != "" {
|
||||
job.TargetID = &target.ID
|
||||
}
|
||||
|
||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||
fmt.Printf("failed to create deployment job for target %s: %v\n", target.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
jobIDs = append(jobIDs, job.ID)
|
||||
}
|
||||
|
||||
if len(jobIDs) == 0 {
|
||||
return nil, fmt.Errorf("failed to create any deployment jobs")
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"deployment_jobs_created", "certificate", certID,
|
||||
map[string]interface{}{"target_count": len(targets), "job_count": len(jobIDs)})
|
||||
|
||||
return jobIDs, nil
|
||||
}
|
||||
|
||||
// ProcessDeploymentJob handles a deployment job by coordinating with an agent.
|
||||
func (s *DeploymentService) ProcessDeploymentJob(ctx context.Context, job *domain.Job) error {
|
||||
// Update job status to in-progress
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Fetch certificate
|
||||
cert, err := s.certRepo.Get(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("certificate fetch failed: %v", err))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
// Fetch target
|
||||
var targetID string
|
||||
if job.TargetID != nil {
|
||||
targetID = *job.TargetID
|
||||
}
|
||||
if targetID == "" {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, "target_id not found in job")
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("target_id not found in job")
|
||||
}
|
||||
|
||||
target, err := s.targetRepo.Get(ctx, targetID)
|
||||
if err != nil {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("target fetch failed: %v", err))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to fetch target: %w", err)
|
||||
}
|
||||
|
||||
// Verify agent is available
|
||||
agentID := target.AgentID
|
||||
agent, err := s.agentRepo.Get(ctx, agentID)
|
||||
if err != nil {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("agent fetch failed: %v", err))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Check agent heartbeat (must be within last 5 minutes)
|
||||
if agent.LastHeartbeatAt != nil && time.Since(*agent.LastHeartbeatAt) > 5*time.Minute {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, "agent is offline")
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
|
||||
_ = s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf("agent offline"))
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"deployment_job_failed", "certificate", job.CertificateID,
|
||||
map[string]interface{}{"job_id": job.ID, "reason": "agent offline", "target_id": targetID})
|
||||
|
||||
return fmt.Errorf("agent %s is offline", agentID)
|
||||
}
|
||||
|
||||
// In a real implementation, the agent would poll GetPendingWork() to fetch this job.
|
||||
// The control plane would wait for the agent to complete the work asynchronously.
|
||||
// For now, we mark it as pending and rely on agent polling.
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"deployment_job_dispatched", "certificate", job.CertificateID,
|
||||
map[string]interface{}{"job_id": job.ID, "target_id": targetID, "agent_id": agentID})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDeployment checks the deployment status of a certificate on a target.
|
||||
func (s *DeploymentService) ValidateDeployment(ctx context.Context, certID string, targetID string) (bool, error) {
|
||||
// List deployment jobs for this certificate and target
|
||||
jobs, err := s.jobRepo.ListByCertificate(ctx, certID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to list jobs: %w", err)
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
if job.Type != domain.JobTypeDeployment {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this job is for the target
|
||||
if job.TargetID == nil || *job.TargetID != targetID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the most recent job for this target succeeded
|
||||
if job.Status == domain.JobStatusCompleted {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if job.Status == domain.JobStatusFailed {
|
||||
if job.LastError != nil {
|
||||
return false, fmt.Errorf("deployment failed: %s", *job.LastError)
|
||||
}
|
||||
return false, fmt.Errorf("deployment failed")
|
||||
}
|
||||
|
||||
// Still in progress
|
||||
return false, fmt.Errorf("deployment in progress")
|
||||
}
|
||||
|
||||
// No deployment job found
|
||||
return false, fmt.Errorf("no deployment job found for target %s", targetID)
|
||||
}
|
||||
|
||||
// MarkDeploymentComplete marks a deployment job as successfully completed.
|
||||
// This is called by agents after they finish deploying a certificate.
|
||||
func (s *DeploymentService) MarkDeploymentComplete(ctx context.Context, jobID string) error {
|
||||
job, err := s.jobRepo.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch job: %w", err)
|
||||
}
|
||||
|
||||
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusCompleted, ""); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Fetch certificate and target for notification
|
||||
cert, err := s.certRepo.Get(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to fetch certificate for notification: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetID string
|
||||
if job.TargetID != nil {
|
||||
targetID = *job.TargetID
|
||||
}
|
||||
|
||||
if targetID != "" {
|
||||
target, err := s.targetRepo.Get(ctx, targetID)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to fetch target for notification: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send deployment success notification
|
||||
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, true, nil); err != nil {
|
||||
fmt.Printf("failed to send deployment notification: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"deployment_job_completed", "certificate", job.CertificateID,
|
||||
map[string]interface{}{"job_id": jobID, "target_id": targetID})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkDeploymentFailed marks a deployment job as failed.
|
||||
// Called by agents when deployment fails.
|
||||
func (s *DeploymentService) MarkDeploymentFailed(ctx context.Context, jobID string, errMsg string) error {
|
||||
job, err := s.jobRepo.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch job: %w", err)
|
||||
}
|
||||
|
||||
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusFailed, errMsg); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Fetch certificate and target for notification
|
||||
cert, err := s.certRepo.Get(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to fetch certificate for notification: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetID string
|
||||
if job.TargetID != nil {
|
||||
targetID = *job.TargetID
|
||||
}
|
||||
|
||||
if targetID != "" {
|
||||
target, err := s.targetRepo.Get(ctx, targetID)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to fetch target for notification: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send deployment failure notification
|
||||
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf(errMsg)); err != nil {
|
||||
fmt.Printf("failed to send deployment notification: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"deployment_job_failed", "certificate", job.CertificateID,
|
||||
map[string]interface{}{"job_id": jobID, "target_id": targetID, "error": errMsg})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// JobService manages job processing and status tracking.
|
||||
// It coordinates between the scheduler and various job-specific services.
|
||||
type JobService struct {
|
||||
jobRepo repository.JobRepository
|
||||
renewalService *RenewalService
|
||||
deploymentService *DeploymentService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewJobService creates a new job service.
|
||||
func NewJobService(
|
||||
jobRepo repository.JobRepository,
|
||||
renewalService *RenewalService,
|
||||
deploymentService *DeploymentService,
|
||||
logger *slog.Logger,
|
||||
) *JobService {
|
||||
return &JobService{
|
||||
jobRepo: jobRepo,
|
||||
renewalService: renewalService,
|
||||
deploymentService: deploymentService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessPendingJobs fetches and processes all pending jobs.
|
||||
// It routes jobs to the appropriate service based on job type and handles errors gracefully.
|
||||
func (s *JobService) ProcessPendingJobs(ctx context.Context) error {
|
||||
// Fetch pending jobs
|
||||
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list pending jobs: %w", err)
|
||||
}
|
||||
|
||||
if len(pendingJobs) == 0 {
|
||||
s.logger.Debug("no pending jobs to process")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("processing pending jobs", "count", len(pendingJobs))
|
||||
|
||||
var processedCount int
|
||||
var failedCount int
|
||||
|
||||
// Process each job
|
||||
for _, job := range pendingJobs {
|
||||
if err := s.processJob(ctx, job); err != nil {
|
||||
s.logger.Error("failed to process job",
|
||||
"job_id", job.ID,
|
||||
"job_type", job.Type,
|
||||
"error", err)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
processedCount++
|
||||
}
|
||||
|
||||
s.logger.Info("job processing completed",
|
||||
"processed", processedCount,
|
||||
"failed", failedCount,
|
||||
"total", len(pendingJobs))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processJob routes a single job to the appropriate service based on type.
|
||||
func (s *JobService) processJob(ctx context.Context, job *domain.Job) error {
|
||||
s.logger.Debug("processing job",
|
||||
"job_id", job.ID,
|
||||
"job_type", job.Type,
|
||||
"certificate_id", job.CertificateID)
|
||||
|
||||
switch job.Type {
|
||||
case domain.JobTypeRenewal:
|
||||
return s.renewalService.ProcessRenewalJob(ctx, job)
|
||||
case domain.JobTypeDeployment:
|
||||
return s.deploymentService.ProcessDeploymentJob(ctx, job)
|
||||
case domain.JobTypeIssuance:
|
||||
return s.processIssuanceJob(ctx, job)
|
||||
case domain.JobTypeValidation:
|
||||
return s.processValidationJob(ctx, job)
|
||||
default:
|
||||
return fmt.Errorf("unknown job type: %s", job.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// processIssuanceJob handles a certificate issuance job.
|
||||
// This is a placeholder that documents the flow.
|
||||
// TODO: Implement actual issuance job processing if needed.
|
||||
func (s *JobService) processIssuanceJob(ctx context.Context, job *domain.Job) error {
|
||||
s.logger.Debug("processing issuance job", "job_id", job.ID)
|
||||
|
||||
// TODO: Implement issuance job processing
|
||||
// In production:
|
||||
// 1. Fetch the certificate
|
||||
// 2. Fetch the issuer
|
||||
// 3. Generate or retrieve CSR
|
||||
// 4. Call issuer to issue new certificate
|
||||
// 5. Create certificate version
|
||||
// 6. Update certificate status
|
||||
// 7. Mark job as completed
|
||||
|
||||
return fmt.Errorf("issuance job processing not yet implemented")
|
||||
}
|
||||
|
||||
// processValidationJob handles a certificate validation job.
|
||||
// This is a placeholder that documents the flow.
|
||||
// TODO: Implement actual validation job processing if needed.
|
||||
func (s *JobService) processValidationJob(ctx context.Context, job *domain.Job) error {
|
||||
s.logger.Debug("processing validation job", "job_id", job.ID)
|
||||
|
||||
// TODO: Implement validation job processing
|
||||
// In production:
|
||||
// 1. Fetch the certificate
|
||||
// 2. For each target, call target connector ValidateDeployment
|
||||
// 3. Aggregate results
|
||||
// 4. Update job status based on results
|
||||
// 5. Send notification if any validation fails
|
||||
|
||||
return fmt.Errorf("validation job processing not yet implemented")
|
||||
}
|
||||
|
||||
// RetryFailedJobs finds failed jobs and resets them for retry.
|
||||
// It only retries jobs that haven't exceeded max attempts.
|
||||
func (s *JobService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
|
||||
s.logger.Debug("retrying failed jobs", "max_retries", maxRetries)
|
||||
|
||||
failedJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusFailed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch failed jobs: %w", err)
|
||||
}
|
||||
|
||||
var retriedCount int
|
||||
|
||||
for _, job := range failedJobs {
|
||||
// Check if we can retry (Attempts < MaxAttempts)
|
||||
if job.Attempts >= job.MaxAttempts {
|
||||
s.logger.Debug("job exceeded max retries",
|
||||
"job_id", job.ID,
|
||||
"attempts", job.Attempts,
|
||||
"max_attempts", job.MaxAttempts)
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset status to pending for retry
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusPending, ""); err != nil {
|
||||
s.logger.Error("failed to reset job status for retry",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
retriedCount++
|
||||
}
|
||||
|
||||
s.logger.Info("failed jobs retry completed",
|
||||
"retried", retriedCount,
|
||||
"total_failed", len(failedJobs))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJobStatus returns the current status of a job.
|
||||
func (s *JobService) GetJobStatus(ctx context.Context, jobID string) (*domain.Job, error) {
|
||||
job, err := s.jobRepo.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch job: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// CancelJob cancels a pending or running job.
|
||||
func (s *JobService) CancelJob(ctx context.Context, jobID string) error {
|
||||
job, err := s.jobRepo.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch job: %w", err)
|
||||
}
|
||||
|
||||
if job.Status != domain.JobStatusPending && job.Status != domain.JobStatusRunning {
|
||||
return fmt.Errorf("cannot cancel job with status %s", job.Status)
|
||||
}
|
||||
|
||||
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusCancelled, "cancelled by user"); err != nil {
|
||||
return fmt.Errorf("failed to cancel job: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("job cancelled", "job_id", jobID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// NotificationService provides business logic for managing notifications.
|
||||
type NotificationService struct {
|
||||
notifRepo repository.NotificationRepository
|
||||
notifierRegistry map[string]Notifier
|
||||
}
|
||||
|
||||
// Notifier defines the interface for notification channels (email, Slack, webhooks, etc.).
|
||||
type Notifier interface {
|
||||
// Send delivers a notification and returns error if unsuccessful.
|
||||
Send(ctx context.Context, recipient string, subject string, body string) error
|
||||
// Channel returns the channel identifier (e.g., "email", "slack").
|
||||
Channel() string
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new notification service.
|
||||
func NewNotificationService(
|
||||
notifRepo repository.NotificationRepository,
|
||||
notifierRegistry map[string]Notifier,
|
||||
) *NotificationService {
|
||||
return &NotificationService{
|
||||
notifRepo: notifRepo,
|
||||
notifierRegistry: notifierRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
// SendExpirationWarning sends a certificate expiration warning.
|
||||
func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int) error {
|
||||
body := fmt.Sprintf(
|
||||
"The certificate for %s will expire in %d days (%s).\n\nPlease schedule renewal.",
|
||||
cert.CommonName, daysUntilExpiry, cert.ExpiresAt.Format("2006-01-02"),
|
||||
)
|
||||
|
||||
// Create notification record
|
||||
notif := &domain.NotificationEvent{
|
||||
ID: generateID("notif"),
|
||||
CertificateID: &cert.ID,
|
||||
Type: domain.NotificationTypeExpirationWarning,
|
||||
Channel: domain.NotificationChannelEmail,
|
||||
Recipient: cert.OwnerID,
|
||||
Message: body,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.notifRepo.Create(ctx, notif); err != nil {
|
||||
return fmt.Errorf("failed to create notification: %w", err)
|
||||
}
|
||||
|
||||
// Attempt immediate send
|
||||
return s.sendNotification(ctx, notif)
|
||||
}
|
||||
|
||||
// SendRenewalNotification sends a renewal success or failure notification.
|
||||
func (s *NotificationService) SendRenewalNotification(ctx context.Context, cert *domain.ManagedCertificate, success bool, err error) error {
|
||||
var body string
|
||||
if success {
|
||||
body = fmt.Sprintf(
|
||||
"The certificate for %s has been successfully renewed.\n\nNew expiry: %s",
|
||||
cert.CommonName, cert.ExpiresAt.Format("2006-01-02"),
|
||||
)
|
||||
} else {
|
||||
body = fmt.Sprintf(
|
||||
"The certificate for %s failed to renew.\n\nError: %v\n\nPlease investigate.",
|
||||
cert.CommonName, err,
|
||||
)
|
||||
}
|
||||
|
||||
var notifType domain.NotificationType
|
||||
if success {
|
||||
notifType = domain.NotificationTypeRenewalSuccess
|
||||
} else {
|
||||
notifType = domain.NotificationTypeRenewalFailure
|
||||
}
|
||||
|
||||
notif := &domain.NotificationEvent{
|
||||
ID: generateID("notif"),
|
||||
CertificateID: &cert.ID,
|
||||
Type: notifType,
|
||||
Channel: domain.NotificationChannelEmail,
|
||||
Recipient: cert.OwnerID,
|
||||
Message: body,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.notifRepo.Create(ctx, notif); err != nil {
|
||||
return fmt.Errorf("failed to create notification: %w", err)
|
||||
}
|
||||
|
||||
return s.sendNotification(ctx, notif)
|
||||
}
|
||||
|
||||
// SendDeploymentNotification sends a deployment success or failure notification.
|
||||
func (s *NotificationService) SendDeploymentNotification(ctx context.Context, cert *domain.ManagedCertificate, target *domain.DeploymentTarget, success bool, err error) error {
|
||||
var body string
|
||||
|
||||
if success {
|
||||
body = fmt.Sprintf(
|
||||
"The certificate for %s has been successfully deployed to %s.",
|
||||
cert.CommonName, target.Name,
|
||||
)
|
||||
} else {
|
||||
body = fmt.Sprintf(
|
||||
"The certificate for %s failed to deploy to %s.\n\nError: %v\n\nPlease investigate.",
|
||||
cert.CommonName, target.Name, err,
|
||||
)
|
||||
}
|
||||
|
||||
notifType := domain.NotificationTypeDeploymentSuccess
|
||||
if !success {
|
||||
notifType = domain.NotificationTypeDeploymentFailure
|
||||
}
|
||||
|
||||
notif := &domain.NotificationEvent{
|
||||
ID: generateID("notif"),
|
||||
CertificateID: &cert.ID,
|
||||
Type: notifType,
|
||||
Channel: domain.NotificationChannelEmail,
|
||||
Recipient: cert.OwnerID,
|
||||
Message: body,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.notifRepo.Create(ctx, notif); err != nil {
|
||||
return fmt.Errorf("failed to create notification: %w", err)
|
||||
}
|
||||
|
||||
return s.sendNotification(ctx, notif)
|
||||
}
|
||||
|
||||
// ProcessPendingNotifications sends all pending notifications in batch.
|
||||
func (s *NotificationService) ProcessPendingNotifications(ctx context.Context) error {
|
||||
filter := &repository.NotificationFilter{
|
||||
Status: "pending",
|
||||
PerPage: 1000,
|
||||
}
|
||||
|
||||
pending, err := s.notifRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list pending notifications: %w", err)
|
||||
}
|
||||
|
||||
var failedCount int
|
||||
|
||||
for _, notif := range pending {
|
||||
if err := s.sendNotification(ctx, notif); err != nil {
|
||||
fmt.Printf("failed to send notification %s: %v\n", notif.ID, err)
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if failedCount > 0 {
|
||||
return fmt.Errorf("failed to send %d out of %d notifications", failedCount, len(pending))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendNotification delivers a single notification via the appropriate channel.
|
||||
func (s *NotificationService) sendNotification(ctx context.Context, notif *domain.NotificationEvent) error {
|
||||
// Get the appropriate notifier for the channel
|
||||
notifier, ok := s.notifierRegistry[string(notif.Channel)]
|
||||
if !ok {
|
||||
return fmt.Errorf("notifier not found for channel %s", notif.Channel)
|
||||
}
|
||||
|
||||
// Send the notification
|
||||
if err := notifier.Send(ctx, notif.Recipient, string(notif.Type), notif.Message); err != nil {
|
||||
// Update status to failed
|
||||
_ = s.notifRepo.UpdateStatus(ctx, notif.ID, "failed", time.Time{})
|
||||
return fmt.Errorf("failed to send via %s: %w", notif.Channel, err)
|
||||
}
|
||||
|
||||
// Update status to sent
|
||||
if err := s.notifRepo.UpdateStatus(ctx, notif.ID, "sent", time.Now()); err != nil {
|
||||
fmt.Printf("failed to update notification status: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterNotifier registers a new notification channel handler.
|
||||
func (s *NotificationService) RegisterNotifier(channel string, notifier Notifier) {
|
||||
if s.notifierRegistry == nil {
|
||||
s.notifierRegistry = make(map[string]Notifier)
|
||||
}
|
||||
s.notifierRegistry[channel] = notifier
|
||||
}
|
||||
|
||||
// GetNotificationHistory returns all notifications for a certificate.
|
||||
func (s *NotificationService) GetNotificationHistory(ctx context.Context, certID string) ([]*domain.NotificationEvent, error) {
|
||||
filter := &repository.NotificationFilter{
|
||||
CertificateID: certID,
|
||||
PerPage: 1000,
|
||||
}
|
||||
|
||||
notifications, err := s.notifRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list notifications: %w", err)
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// PolicyService provides business logic for compliance policy management.
|
||||
type PolicyService struct {
|
||||
policyRepo repository.PolicyRepository
|
||||
auditService *AuditService
|
||||
}
|
||||
|
||||
// NewPolicyService creates a new policy service.
|
||||
func NewPolicyService(
|
||||
policyRepo repository.PolicyRepository,
|
||||
auditService *AuditService,
|
||||
) *PolicyService {
|
||||
return &PolicyService{
|
||||
policyRepo: policyRepo,
|
||||
auditService: auditService,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateCertificate runs all enabled policy rules against a certificate.
|
||||
func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.ManagedCertificate) ([]*domain.PolicyViolation, error) {
|
||||
rules, err := s.policyRepo.ListRules(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list policy rules: %w", err)
|
||||
}
|
||||
|
||||
var violations []*domain.PolicyViolation
|
||||
|
||||
for _, rule := range rules {
|
||||
// Skip disabled rules
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Evaluate rule against certificate
|
||||
v, err := s.evaluateRule(rule, cert)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to evaluate rule %s: %v\n", rule.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if v != nil {
|
||||
violations = append(violations, v)
|
||||
}
|
||||
}
|
||||
|
||||
return violations, nil
|
||||
}
|
||||
|
||||
// evaluateRule checks if a certificate violates a single policy rule.
|
||||
func (s *PolicyService) evaluateRule(rule *domain.PolicyRule, cert *domain.ManagedCertificate) (*domain.PolicyViolation, error) {
|
||||
switch rule.Type {
|
||||
case domain.PolicyTypeAllowedIssuers:
|
||||
// Restrict to specific issuers
|
||||
// Note: In a production implementation, we would parse rule.Config to extract parameters
|
||||
if cert.IssuerID == "" {
|
||||
return &domain.PolicyViolation{
|
||||
ID: generateID("violation"),
|
||||
RuleID: rule.ID,
|
||||
CertificateID: cert.ID,
|
||||
Severity: domain.PolicySeverityWarning,
|
||||
Message: "certificate has no issuer assigned",
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
case domain.PolicyTypeAllowedDomains:
|
||||
// Ensure certificate domains are in allowed list
|
||||
if len(cert.SANs) == 0 {
|
||||
return &domain.PolicyViolation{
|
||||
ID: generateID("violation"),
|
||||
RuleID: rule.ID,
|
||||
CertificateID: cert.ID,
|
||||
Severity: domain.PolicySeverityWarning,
|
||||
Message: "certificate has no subject alternative names",
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
case domain.PolicyTypeRequiredMetadata:
|
||||
// Ensure certificate has required metadata/tags
|
||||
if len(cert.Tags) == 0 {
|
||||
return &domain.PolicyViolation{
|
||||
ID: generateID("violation"),
|
||||
RuleID: rule.ID,
|
||||
CertificateID: cert.ID,
|
||||
Severity: domain.PolicySeverityWarning,
|
||||
Message: "certificate has no tags or metadata",
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
case domain.PolicyTypeAllowedEnvironments:
|
||||
// Restrict to specific environments
|
||||
if cert.Environment == "" {
|
||||
return &domain.PolicyViolation{
|
||||
ID: generateID("violation"),
|
||||
RuleID: rule.ID,
|
||||
CertificateID: cert.ID,
|
||||
Severity: domain.PolicySeverityWarning,
|
||||
Message: "certificate has no environment assigned",
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
case domain.PolicyTypeRenewalLeadTime:
|
||||
// Ensure renewal begins before certificate expires
|
||||
daysUntilExpiry := time.Until(cert.ExpiresAt).Hours() / 24
|
||||
if daysUntilExpiry < 30 && daysUntilExpiry > 0 {
|
||||
return &domain.PolicyViolation{
|
||||
ID: generateID("violation"),
|
||||
RuleID: rule.ID,
|
||||
CertificateID: cert.ID,
|
||||
Severity: domain.PolicySeverityWarning,
|
||||
Message: fmt.Sprintf("certificate expires in %.1f days, plan renewal soon", daysUntilExpiry),
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown policy rule type: %s", rule.Type)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CreateRule stores a new policy rule.
|
||||
func (s *PolicyService) CreateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
|
||||
if rule.ID == "" {
|
||||
rule.ID = generateID("rule")
|
||||
}
|
||||
if rule.CreatedAt.IsZero() {
|
||||
rule.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
if err := s.policyRepo.CreateRule(ctx, rule); err != nil {
|
||||
return fmt.Errorf("failed to create policy rule: %w", err)
|
||||
}
|
||||
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"policy_rule_created", "policy", rule.ID,
|
||||
map[string]interface{}{"rule_type": rule.Type}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRule modifies an existing policy rule.
|
||||
func (s *PolicyService) UpdateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
|
||||
existing, err := s.policyRepo.GetRule(ctx, rule.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch existing rule: %w", err)
|
||||
}
|
||||
|
||||
rule.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.policyRepo.UpdateRule(ctx, rule); err != nil {
|
||||
return fmt.Errorf("failed to update policy rule: %w", err)
|
||||
}
|
||||
|
||||
changes := map[string]interface{}{}
|
||||
if existing.Enabled != rule.Enabled {
|
||||
changes["enabled"] = rule.Enabled
|
||||
}
|
||||
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"policy_rule_updated", "policy", rule.ID, changes); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRule retrieves a policy rule by ID.
|
||||
func (s *PolicyService) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
|
||||
rule, err := s.policyRepo.GetRule(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch policy rule: %w", err)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// ListRules returns all policy rules.
|
||||
func (s *PolicyService) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
|
||||
rules, err := s.policyRepo.ListRules(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list policy rules: %w", err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// DeleteRule removes a policy rule.
|
||||
func (s *PolicyService) DeleteRule(ctx context.Context, id string, actor string) error {
|
||||
rule, err := s.policyRepo.GetRule(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch rule: %w", err)
|
||||
}
|
||||
|
||||
if err := s.policyRepo.DeleteRule(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to delete policy rule: %w", err)
|
||||
}
|
||||
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"policy_rule_deleted", "policy", id,
|
||||
map[string]interface{}{"rule_type": rule.Type}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListViolations returns policy violations matching filter criteria.
|
||||
func (s *PolicyService) ListViolations(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
|
||||
violations, err := s.policyRepo.ListViolations(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list policy violations: %w", err)
|
||||
}
|
||||
return violations, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// RenewalService manages certificate renewal workflows.
|
||||
type RenewalService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
}
|
||||
|
||||
// IssuerConnector defines the interface for interacting with certificate issuers.
|
||||
type IssuerConnector interface {
|
||||
// RenewCertificate renews a certificate and returns the new certificate PEM.
|
||||
RenewCertificate(ctx context.Context, csr []byte) ([]byte, error)
|
||||
// GetCertificateChain returns the issuer's certificate chain.
|
||||
GetCertificateChain(ctx context.Context) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewRenewalService creates a new renewal service.
|
||||
func NewRenewalService(
|
||||
certRepo repository.CertificateRepository,
|
||||
jobRepo repository.JobRepository,
|
||||
auditService *AuditService,
|
||||
notificationSvc *NotificationService,
|
||||
issuerRegistry map[string]IssuerConnector,
|
||||
) *RenewalService {
|
||||
return &RenewalService{
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
auditService: auditService,
|
||||
notificationSvc: notificationSvc,
|
||||
issuerRegistry: issuerRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckExpiringCertificates identifies certificates needing renewal based on policy windows.
|
||||
func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
// Default renewal window: 30 days before expiry
|
||||
renewalWindow := time.Now().AddDate(0, 0, 30)
|
||||
|
||||
expiring, err := s.certRepo.GetExpiringCertificates(ctx, renewalWindow)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch expiring certificates: %w", err)
|
||||
}
|
||||
|
||||
for _, cert := range expiring {
|
||||
// Skip if already renewing or archived
|
||||
if cert.Status == domain.CertificateStatusRenewalInProgress || cert.Status == domain.CertificateStatusArchived {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate days until expiry
|
||||
daysUntil := time.Until(cert.ExpiresAt).Hours() / 24
|
||||
|
||||
// Create renewal job
|
||||
job := &domain.Job{
|
||||
ID: generateID("job"),
|
||||
CertificateID: cert.ID,
|
||||
Type: domain.JobTypeRenewal,
|
||||
Status: domain.JobStatusPending,
|
||||
ScheduledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||
fmt.Printf("failed to create renewal job for cert %s: %v\n", cert.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send expiration warning notification
|
||||
if err := s.notificationSvc.SendExpirationWarning(ctx, cert, int(daysUntil)); err != nil {
|
||||
fmt.Printf("failed to send expiration warning for cert %s: %v\n", cert.ID, err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"renewal_job_created", "certificate", cert.ID,
|
||||
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessRenewalJob executes a renewal job: call issuer, store new version, update cert status.
|
||||
func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job) error {
|
||||
// Update job status to in-progress
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Fetch certificate
|
||||
cert, err := s.certRepo.Get(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("certificate fetch failed: %v", err))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
// Get issuer connector
|
||||
issuerID := cert.IssuerID
|
||||
if issuerID == "" {
|
||||
return fmt.Errorf("certificate has no issuer assigned")
|
||||
}
|
||||
|
||||
connector, ok := s.issuerRegistry[issuerID]
|
||||
if !ok {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed,
|
||||
fmt.Sprintf("issuer connector not found for %s", issuerID))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("issuer connector not found for %s", issuerID)
|
||||
}
|
||||
|
||||
// TODO: In production, fetch CSR from agent or generate new CSR
|
||||
// For now, we'd use cert.CSR or generate a new one from the private key
|
||||
csr := []byte{} // placeholder
|
||||
|
||||
// Call issuer to renew
|
||||
certPEM, err := connector.RenewCertificate(ctx, csr)
|
||||
if err != nil {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("issuer renewal failed: %v", err))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
|
||||
// Send failure notification
|
||||
_ = s.notificationSvc.SendRenewalNotification(ctx, cert, false, err)
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"renewal_job_failed", "certificate", job.CertificateID,
|
||||
map[string]interface{}{"job_id": job.ID, "error": err.Error()})
|
||||
|
||||
return fmt.Errorf("issuer renewal failed: %w", err)
|
||||
}
|
||||
|
||||
// Create new certificate version
|
||||
version := &domain.CertificateVersion{
|
||||
ID: generateID("certver"),
|
||||
CertificateID: job.CertificateID,
|
||||
SerialNumber: fmt.Sprintf("renewed-%d", time.Now().Unix()),
|
||||
PEMChain: string(certPEM),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("version creation failed: %v", err))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to create certificate version: %w", err)
|
||||
}
|
||||
|
||||
// Update certificate status
|
||||
cert.Status = domain.CertificateStatusActive
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("cert update failed: %v", err))
|
||||
if updateErr != nil {
|
||||
fmt.Printf("failed to update job status: %v\n", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to update certificate: %w", err)
|
||||
}
|
||||
|
||||
// Mark job as completed
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusCompleted, ""); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Send success notification
|
||||
if err := s.notificationSvc.SendRenewalNotification(ctx, cert, true, nil); err != nil {
|
||||
fmt.Printf("failed to send renewal notification: %v\n", err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"renewal_job_completed", "certificate", job.CertificateID,
|
||||
map[string]interface{}{"job_id": job.ID, "serial": version.SerialNumber})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry attempts to reprocess failed renewal jobs with exponential backoff.
|
||||
func (s *RenewalService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
|
||||
failedJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusFailed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch failed jobs: %w", err)
|
||||
}
|
||||
|
||||
for _, job := range failedJobs {
|
||||
if job.Type != domain.JobTypeRenewal {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we've exceeded max attempts
|
||||
if job.Attempts >= job.MaxAttempts {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset status to pending for retry
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusPending, ""); err != nil {
|
||||
fmt.Printf("failed to reset job status for retry: %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
|
||||
func generateID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
Reference in New Issue
Block a user