feat: hook manager

This commit is contained in:
Fernandez Ludovic 2026-02-08 00:03:29 +01:00
commit c174a0c257
11 changed files with 489 additions and 96 deletions

View file

@ -61,11 +61,6 @@ func renew(ctx context.Context, cmd *cli.Command) error {
certsStorage := storage.NewCertificatesStorage(cmd.String(flgPath))
meta := map[string]string{
// TODO(ldez) add account ID.
hook.EnvAccountEmail: account.Email,
}
lazyClient := sync.OnceValues(func() (*lego.Client, error) {
client, err := newClient(cmd, account, keyType)
if err != nil {
@ -77,16 +72,18 @@ func renew(ctx context.Context, cmd *cli.Command) error {
return client, nil
})
hookManager := newHookManager(cmd, certsStorage, account)
// CSR
if cmd.IsSet(flgCSR) {
return renewForCSR(ctx, cmd, lazyClient, certsStorage, meta)
return renewForCSR(ctx, cmd, lazyClient, certsStorage, hookManager)
}
// Domains
return renewForDomains(ctx, cmd, lazyClient, certsStorage, meta)
return renewForDomains(ctx, cmd, lazyClient, certsStorage, hookManager)
}
func renewForDomains(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certsStorage *storage.CertificatesStorage, meta map[string]string) error {
func renewForDomains(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certsStorage *storage.CertificatesStorage, hookManager *hook.Manager) error {
domains := cmd.StringSlice(flgDomains)
certID := cmd.String(flgCertName)
@ -147,6 +144,13 @@ func renewForDomains(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp,
slog.Any("time-remaining", FormattableDuration(cert.NotAfter.Sub(time.Now().UTC()))),
)
err = hookManager.Pre(ctx, certID, renewalDomains)
if err != nil {
return fmt.Errorf("pre-renew hook: %w", err)
}
defer func() { _ = hookManager.Post(ctx) }()
client, err := lazyClient()
if err != nil {
return fmt.Errorf("set up client: %w", err)
@ -181,12 +185,10 @@ func renewForDomains(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp,
return fmt.Errorf("could not save the resource: %w", err)
}
hook.AddPathToMetadata(meta, certRes, certsStorage, options)
return hook.Launch(ctx, cmd.String(flgDeployHook), cmd.Duration(flgDeployHookTimeout), meta)
return hookManager.Deploy(ctx, certRes, options)
}
func renewForCSR(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certsStorage *storage.CertificatesStorage, meta map[string]string) error {
func renewForCSR(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certsStorage *storage.CertificatesStorage, hookManager *hook.Manager) error {
csr, err := readCSRFile(cmd.String(flgCSR))
if err != nil {
return fmt.Errorf("could not read CSR file %q: %w", cmd.String(flgCSR), err)
@ -231,6 +233,13 @@ func renewForCSR(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, cert
slog.Any("time-remaining", FormattableDuration(cert.NotAfter.Sub(time.Now().UTC()))),
)
err = hookManager.Pre(ctx, certID, certcrypto.ExtractDomainsCSR(csr))
if err != nil {
return fmt.Errorf("CSR: pre-renew hook: %w", err)
}
defer func() { _ = hookManager.Post(ctx) }()
client, err := lazyClient()
if err != nil {
return fmt.Errorf("set up client: %w", err)
@ -256,9 +265,7 @@ func renewForCSR(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, cert
return fmt.Errorf("CSR: could not save the resource: %w", err)
}
hook.AddPathToMetadata(meta, certRes, certsStorage, options)
return hook.Launch(ctx, cmd.String(flgDeployHook), cmd.Duration(flgDeployHookTimeout), meta)
return hookManager.Deploy(ctx, certRes, options)
}
func getFlagRenewDays(cmd *cli.Command) int {

View file

@ -39,6 +39,10 @@ func run(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("set up account: %w", err)
}
certsStorage := storage.NewCertificatesStorage(cmd.String(flgPath))
hookManager := newHookManager(cmd, certsStorage, account)
client, err := newClient(cmd, account, keyType)
if err != nil {
return fmt.Errorf("new client: %w", err)
@ -62,7 +66,7 @@ func run(ctx context.Context, cmd *cli.Command) error {
setupChallenges(cmd, client)
certRes, err := obtainCertificate(ctx, cmd, client)
certRes, err := obtainCertificate(ctx, cmd, client, hookManager)
if err != nil {
// Make sure to return a non-zero exit code if ObtainSANCertificate returned at least one error.
// Due to us not returning partial certificate we can just exit here instead of at the end.
@ -74,8 +78,6 @@ func run(ctx context.Context, cmd *cli.Command) error {
certRes.ID = certID
}
certsStorage := storage.NewCertificatesStorage(cmd.String(flgPath))
options := newSaveOptions(cmd)
err = certsStorage.Save(certRes, options)
@ -83,20 +85,20 @@ func run(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("could not save the resource: %w", err)
}
meta := map[string]string{
// TODO(ldez) add account ID.
hook.EnvAccountEmail: account.Email,
}
hook.AddPathToMetadata(meta, certRes, certsStorage, options)
return hook.Launch(ctx, cmd.String(flgDeployHook), cmd.Duration(flgDeployHookTimeout), meta)
return hookManager.Deploy(ctx, certRes, options)
}
func obtainCertificate(ctx context.Context, cmd *cli.Command, client *lego.Client) (*certificate.Resource, error) {
func obtainCertificate(ctx context.Context, cmd *cli.Command, client *lego.Client, hookManager *hook.Manager) (*certificate.Resource, error) {
domains := cmd.StringSlice(flgDomains)
if len(domains) > 0 {
err := hookManager.Pre(ctx, cmd.String(flgCertName), domains)
if err != nil {
return nil, err
}
defer func() { _ = hookManager.Post(ctx) }()
// obtain a certificate, generating a new private key
request := newObtainRequest(cmd, domains)
@ -119,6 +121,13 @@ func obtainCertificate(ctx context.Context, cmd *cli.Command, client *lego.Clien
return nil, err
}
err = hookManager.Pre(ctx, cmd.String(flgCertName), certcrypto.ExtractDomainsCSR(csr))
if err != nil {
return nil, err
}
defer func() { _ = hookManager.Post(ctx) }()
// obtain a certificate for this CSR
request := newObtainForCSRRequest(cmd, csr)

View file

@ -143,8 +143,12 @@ const (
// Flags names related to hooks.
const (
flgPreHook = "pre-hook"
flgPreHookTimeout = "pre-hook-timeout"
flgDeployHook = "deploy-hook"
flgDeployHookTimeout = "deploy-hook-timeout"
flgPostHook = "post-hook"
flgPostHookTimeout = "post-hook-timeout"
)
// Flag names related to logs.
@ -614,13 +618,31 @@ func createObtainFlags() []cli.Flag {
}
}
func createHookFlags() []cli.Flag {
func createPreHookFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Category: categoryHooks,
Name: flgPreHook,
Sources: cli.EnvVars(toEnvName(flgPreHook)),
Usage: "Define a pre-hook. This hook is runs, before the renewal, in cases where a certificate will be effectively renewed.",
},
&cli.DurationFlag{
Category: categoryHooks,
Name: flgPreHookTimeout,
Sources: cli.EnvVars(toEnvName(flgPreHookTimeout)),
Usage: "Define the timeout for the pre-hook execution.",
Value: 2 * time.Minute,
},
}
}
func createDeployHookFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Category: categoryHooks,
Name: flgDeployHook,
Sources: cli.EnvVars(toEnvName(flgDeployHook)),
Usage: "Define a hook. The hook is executed only when the certificates are effectively created/renewed.",
Usage: "Define a hook. The hook is runs, after the renewal, in cases where a certificate is successfully created/renewed.",
},
&cli.DurationFlag{
Category: categoryHooks,
@ -632,6 +654,24 @@ func createHookFlags() []cli.Flag {
}
}
func createPostHookFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Category: categoryHooks,
Name: flgPostHook,
Sources: cli.EnvVars(toEnvName(flgPostHook)),
Usage: "Define a post-hook. This hook runs, after the renewal, in cases where a certificate renewed, regardless of whether any errors occurred.",
},
&cli.DurationFlag{
Category: categoryHooks,
Name: flgPostHookTimeout,
Sources: cli.EnvVars(toEnvName(flgPostHookTimeout)),
Usage: "Define the timeout for the post-hook execution.",
Value: 2 * time.Minute,
},
}
}
func CreateLogFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
@ -663,7 +703,9 @@ func createRunFlags() []cli.Flag {
flags = append(flags, createAcceptFlag())
flags = append(flags, createChallengesFlags()...)
flags = append(flags, createObtainFlags()...)
flags = append(flags, createHookFlags()...)
flags = append(flags, createPreHookFlags()...)
flags = append(flags, createDeployHookFlags()...)
flags = append(flags, createPostHookFlags()...)
flags = append(flags,
&cli.StringFlag{
@ -688,7 +730,9 @@ func createRenewFlags() []cli.Flag {
flags = append(flags, createStorageFlags()...)
flags = append(flags, createChallengesFlags()...)
flags = append(flags, createObtainFlags()...)
flags = append(flags, createHookFlags()...)
flags = append(flags, createPreHookFlags()...)
flags = append(flags, createDeployHookFlags()...)
flags = append(flags, createPostHookFlags()...)
flags = append(flags,
&cli.IntFlag{

View file

@ -9,29 +9,9 @@ import (
"os/exec"
"strings"
"time"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/storage"
)
// TODO(ldez) rename the env vars with LEGO_HOOK_ prefix to avoid collisions with flag names.
const (
EnvAccountEmail = "LEGO_ACCOUNT_EMAIL"
EnvCertDomain = "LEGO_CERT_DOMAIN"
EnvCertPath = "LEGO_CERT_PATH"
EnvCertKeyPath = "LEGO_CERT_KEY_PATH"
EnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH"
EnvCertPEMPath = "LEGO_CERT_PEM_PATH"
EnvCertPFXPath = "LEGO_CERT_PFX_PATH"
)
// TODO(ldez): merge this with the previous constant block.
const (
EnvCertNameSanitized = "LEGO_HOOK_CERT_NAME_SANITIZED"
EnvCertID = "LEGO_HOOK_CERT_ID"
EnvCertDomains = "LEGO_HOOK_CERT_DOMAINS"
)
// Launch executes a command.
func Launch(ctx context.Context, hook string, timeout time.Duration, meta map[string]string) error {
if hook == "" {
return nil
@ -83,36 +63,3 @@ func Launch(ctx context.Context, hook string, timeout time.Duration, meta map[st
return nil
}
func metaToEnv(meta map[string]string) []string {
var envs []string
for k, v := range meta {
envs = append(envs, k+"="+v)
}
return envs
}
// AddPathToMetadata adds information about the certificate to the metadata map.
func AddPathToMetadata(meta map[string]string, certRes *certificate.Resource, certsStorage *storage.CertificatesStorage, options *storage.SaveOptions) {
meta[EnvCertID] = certRes.ID
meta[EnvCertNameSanitized] = storage.SanitizedName(certRes.ID)
meta[EnvCertDomains] = strings.Join(certRes.Domains, ",")
meta[EnvCertPath] = certsStorage.GetFileName(certRes.ID, storage.ExtCert)
meta[EnvCertKeyPath] = certsStorage.GetFileName(certRes.ID, storage.ExtKey)
if certRes.IssuerCertificate != nil {
meta[EnvIssuerCertKeyPath] = certsStorage.GetFileName(certRes.ID, storage.ExtIssuer)
}
if options.PEM {
meta[EnvCertPEMPath] = certsStorage.GetFileName(certRes.ID, storage.ExtPEM)
}
if options.PFX {
meta[EnvCertPFXPath] = certsStorage.GetFileName(certRes.ID, storage.ExtPFX)
}
}

View file

@ -5,7 +5,6 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -60,13 +59,3 @@ func Test_Launch_errors(t *testing.T) {
})
}
}
func Test_metaToEnv(t *testing.T) {
env := metaToEnv(map[string]string{
"foo": "bar",
})
expected := []string{"foo=bar"}
assert.Equal(t, expected, env)
}

View file

@ -0,0 +1,96 @@
package hook
import (
"context"
"fmt"
"time"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/log"
)
// Action represents a hook action.
type Action struct {
Cmd string
Timeout time.Duration
}
// Manager manages hooks.
type Manager struct {
certsStorage *storage.CertificatesStorage
metadata map[string]string
pre *Action
deploy *Action
post *Action
}
// NewManager creates a new hook Manager.
func NewManager(certsStorage *storage.CertificatesStorage, options ...Option) *Manager {
m := &Manager{
certsStorage: certsStorage,
metadata: make(map[string]string),
}
for _, option := range options {
option(m)
}
return m
}
// Pre runs the pre-hook if defined.
func (h *Manager) Pre(ctx context.Context, certID string, domains []string) error {
if h.pre == nil || h.pre.Cmd == "" {
return nil
}
addCertificateMetadata(h.metadata, certID, domains)
err := Launch(ctx, h.pre.Cmd, h.pre.Timeout, h.metadata)
if err != nil {
log.Error("Pre hook.", log.ErrorAttr(err))
return fmt.Errorf("pre hook: %w", err)
}
return nil
}
// Deploy runs the deploy-hook if defined.
func (h *Manager) Deploy(ctx context.Context, certRes *certificate.Resource, options *storage.SaveOptions) error {
if h.deploy == nil || h.deploy.Cmd == "" {
return nil
}
addCertificateMetadata(h.metadata, certRes.ID, certRes.Domains)
addCertificatePathsMetadata(h.metadata, certRes, h.certsStorage, options)
err := Launch(ctx, h.deploy.Cmd, h.deploy.Timeout, h.metadata)
if err != nil {
log.Error("Deploy hook.", log.ErrorAttr(err))
return fmt.Errorf("deploy hook: %w", err)
}
return nil
}
// Post runs the post-hook if defined.
// This must be called inside a defer statement to ensure the hook is always run.
func (h *Manager) Post(ctx context.Context) error {
if h.post == nil || h.post.Cmd == "" {
return nil
}
err := Launch(ctx, h.post.Cmd, h.post.Timeout, h.metadata)
if err != nil {
log.Error("Post hook.", log.ErrorAttr(err))
return fmt.Errorf("post hook: %w", err)
}
return nil
}

View file

@ -0,0 +1,62 @@
package hook
import (
"time"
"github.com/go-acme/lego/v5/cmd/internal/storage"
)
type Option func(m *Manager)
// WithPre sets the pre-hook.
func WithPre(cmd string, timeout time.Duration) Option {
return func(m *Manager) {
if cmd == "" {
return
}
m.pre = &Action{
Cmd: cmd,
Timeout: timeout,
}
}
}
// WithDeploy sets the deploy-hook.
func WithDeploy(cmd string, timeout time.Duration) Option {
return func(m *Manager) {
if cmd == "" {
return
}
m.deploy = &Action{
Cmd: cmd,
Timeout: timeout,
}
}
}
// WithPost sets the post-hook.
func WithPost(cmd string, timeout time.Duration) Option {
return func(m *Manager) {
if cmd == "" {
return
}
m.post = &Action{
Cmd: cmd,
Timeout: timeout,
}
}
}
// WithAccountMetadata initializes the metadata with the account data.
func WithAccountMetadata(account *storage.Account) Option {
return func(m *Manager) {
if account == nil {
return
}
addAccountMetadata(m.metadata, account)
}
}

View file

@ -0,0 +1,144 @@
package hook
import (
"testing"
"time"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/stretchr/testify/require"
)
func Test_Manager(t *testing.T) {
certificatesStorage := storage.NewCertificatesStorage(t.TempDir())
testCases := []struct {
desc string
options []Option
}{
{
desc: "all hooks",
options: []Option{
WithPre("echo Pre Hook", 1*time.Second),
WithDeploy("echo Deploy Hook", 1*time.Second),
WithPost("echo Post Hook", 1*time.Second),
},
},
{
desc: "pre-hook only",
options: []Option{
WithPre("echo Pre Hook", 1*time.Second),
},
},
{
desc: "deploy-hook only",
options: []Option{
WithDeploy("echo Deploy Hook", 1*time.Second),
},
},
{
desc: "post-hook only",
options: []Option{
WithPost("echo Post Hook", 1*time.Second),
},
},
{
desc: "no hook",
},
{
desc: "all hooks (metadata)",
options: []Option{
WithPre("echo Pre Hook", 1*time.Second),
WithDeploy("echo Deploy Hook", 1*time.Second),
WithPost("echo Post Hook", 1*time.Second),
WithAccountMetadata(&storage.Account{ID: "foo@exmaple.com", Email: "bar@example.com"}),
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
manager := NewManager(certificatesStorage, test.options...)
err := manager.Pre(t.Context(), "a", []string{"example.com", "example.org"})
require.NoError(t, err)
t.Log(manager.metadata)
err = manager.Deploy(t.Context(), &certificate.Resource{ID: "example.org"}, &storage.SaveOptions{})
require.NoError(t, err)
t.Log(manager.metadata)
err = manager.Post(t.Context())
require.NoError(t, err)
t.Log(manager.metadata)
})
}
}
func Test_Manager_errors(t *testing.T) {
certificatesStorage := storage.NewCertificatesStorage(t.TempDir())
testCases := []struct {
desc string
options []Option
requirePre require.ErrorAssertionFunc
requireDeploy require.ErrorAssertionFunc
requirePost require.ErrorAssertionFunc
}{
{
desc: "pre-hook error",
options: []Option{
WithPre("thisappdoesnotexistpre", 1*time.Second),
WithDeploy("echo Deploy Hook", 1*time.Second),
WithPost("echo Post Hook", 1*time.Second),
},
requirePre: require.Error,
requireDeploy: require.NoError,
requirePost: require.NoError,
},
{
desc: "deploy-hook error",
options: []Option{
WithPre("echo Pre Hook", 1*time.Second),
WithDeploy("thiscommanddoesnotexistdeploy", 1*time.Second),
WithPost("echo Post Hook", 1*time.Second),
},
requirePre: require.NoError,
requireDeploy: require.Error,
requirePost: require.NoError,
},
{
desc: "post-hook error",
options: []Option{
WithPre("echo Pre Hook", 1*time.Second),
WithDeploy("echo Deploy Hook", 1*time.Second),
WithPost("thiscommanddoesnotexistpost", 1*time.Second),
},
requirePre: require.NoError,
requireDeploy: require.NoError,
requirePost: require.Error,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
manager := NewManager(certificatesStorage, test.options...)
err := manager.Pre(t.Context(), "a", []string{"example.com", "example.org"})
test.requirePre(t, err)
err = manager.Deploy(t.Context(), &certificate.Resource{ID: "example.org"}, &storage.SaveOptions{})
test.requireDeploy(t, err)
err = manager.Post(t.Context())
test.requirePost(t, err)
})
}
}

View file

@ -0,0 +1,67 @@
package hook
import (
"strings"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/storage"
)
// Metadata related to account.
const (
EnvAccountID = "LEGO_HOOK_ACCOUNT_ID"
EnvAccountEmail = "LEGO_HOOK_ACCOUNT_EMAIL"
)
// Metadata related to certificate.
const (
EnvCertName = "LEGO_HOOK_CERT_NAME"
EnvCertNameSanitized = "LEGO_HOOK_CERT_NAME_SANITIZED"
EnvCertDomains = "LEGO_HOOK_CERT_DOMAINS"
EnvCertPath = "LEGO_HOOK_CERT_PATH"
EnvCertKeyPath = "LEGO_HOOK_CERT_KEY_PATH"
EnvIssuerCertKeyPath = "LEGO_HOOK_ISSUER_CERT_PATH"
EnvCertPEMPath = "LEGO_HOOK_CERT_PEM_PATH"
EnvCertPFXPath = "LEGO_HOOK_CERT_PFX_PATH"
)
func addAccountMetadata(meta map[string]string, account *storage.Account) {
meta[EnvAccountID] = account.ID
meta[EnvAccountEmail] = account.Email
}
func addCertificatePathsMetadata(meta map[string]string, certRes *certificate.Resource, certsStorage *storage.CertificatesStorage, options *storage.SaveOptions) {
meta[EnvCertPath] = certsStorage.GetFileName(certRes.ID, storage.ExtCert)
meta[EnvCertKeyPath] = certsStorage.GetFileName(certRes.ID, storage.ExtKey)
if certRes.IssuerCertificate != nil {
meta[EnvIssuerCertKeyPath] = certsStorage.GetFileName(certRes.ID, storage.ExtIssuer)
}
if options.PEM {
meta[EnvCertPEMPath] = certsStorage.GetFileName(certRes.ID, storage.ExtPEM)
}
if options.PFX {
meta[EnvCertPFXPath] = certsStorage.GetFileName(certRes.ID, storage.ExtPFX)
}
}
func addCertificateMetadata(meta map[string]string, certID string, domains []string) {
if certID == "" {
meta[EnvCertName] = certID
meta[EnvCertNameSanitized] = storage.SanitizedName(certID)
}
meta[EnvCertDomains] = strings.Join(domains, ",")
}
func metaToEnv(meta map[string]string) []string {
var envs []string
for k, v := range meta {
envs = append(envs, k+"="+v)
}
return envs
}

View file

@ -0,0 +1,17 @@
package hook
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_metaToEnv(t *testing.T) {
env := metaToEnv(map[string]string{
"foo": "bar",
})
expected := []string{"foo=bar"}
assert.Equal(t, expected, env)
}

View file

@ -17,6 +17,7 @@ import (
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/hook"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/lego"
"github.com/go-acme/lego/v5/log"
@ -204,6 +205,16 @@ func newSaveOptions(cmd *cli.Command) *storage.SaveOptions {
}
}
func newHookManager(cmd *cli.Command, certsStorage *storage.CertificatesStorage, account *storage.Account) *hook.Manager {
return hook.NewManager(
certsStorage,
hook.WithPre(cmd.String(flgPreHook), cmd.Duration(flgPreHookTimeout)),
hook.WithDeploy(cmd.String(flgDeployHook), cmd.Duration(flgDeployHookTimeout)),
hook.WithPost(cmd.String(flgPostHook), cmd.Duration(flgPostHookTimeout)),
hook.WithAccountMetadata(account),
)
}
func parseAddress(cmd *cli.Command, flgName string) (string, string, error) {
address := cmd.String(flgName)