Skip to content

Commit 1bb4421

Browse files
authored
fix(crd): refactor webhook receiver to align with new CRD architecture (#1232)
Signed-off-by: Denis Karpelevich <[email protected]>
1 parent 5d60c1b commit 1bb4421

21 files changed

+1532
-831
lines changed

cmd/common.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"text/template"
8+
"time"
9+
10+
"github.com/argoproj/argo-cd/v2/util/askpass"
11+
"github.com/go-logr/logr"
12+
"go.uber.org/ratelimit"
13+
14+
"github.com/argoproj-labs/argocd-image-updater/internal/controller"
15+
"github.com/argoproj-labs/argocd-image-updater/pkg/argocd"
16+
"github.com/argoproj-labs/argocd-image-updater/pkg/common"
17+
"github.com/argoproj-labs/argocd-image-updater/pkg/webhook"
18+
"github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/registry"
19+
)
20+
21+
// WebhookConfig holds the options for the webhook server
22+
type WebhookConfig struct {
23+
Port int
24+
DockerSecret string
25+
GHCRSecret string
26+
QuaySecret string
27+
HarborSecret string
28+
RateLimitNumAllowedRequests int
29+
}
30+
31+
// SetupCommon initializes common components (logging, context, etc.)
32+
func SetupCommon(ctx context.Context, cfg *controller.ImageUpdaterConfig, setupLogger logr.Logger, commitMessagePath, kubeConfig string) error {
33+
var commitMessageTpl string
34+
35+
// User can specify a path to a template used for Git commit messages
36+
if commitMessagePath != "" {
37+
tpl, err := os.ReadFile(commitMessagePath)
38+
if err != nil {
39+
if errors.Is(err, os.ErrNotExist) {
40+
setupLogger.Info("commit message template not found, using default", "path", commitMessagePath)
41+
commitMessageTpl = common.DefaultGitCommitMessage
42+
} else {
43+
setupLogger.Error(err, "could not read commit message template", "path", commitMessagePath)
44+
return err
45+
}
46+
} else {
47+
commitMessageTpl = string(tpl)
48+
}
49+
}
50+
51+
if commitMessageTpl == "" {
52+
setupLogger.Info("Using default Git commit message template")
53+
commitMessageTpl = common.DefaultGitCommitMessage
54+
}
55+
56+
if tpl, err := template.New("commitMessage").Parse(commitMessageTpl); err != nil {
57+
setupLogger.Error(err, "could not parse commit message template")
58+
return err
59+
} else {
60+
setupLogger.V(1).Info("Successfully parsed commit message template")
61+
cfg.GitCommitMessage = tpl
62+
}
63+
64+
// Load registries configuration early on. We do not consider it a fatal
65+
// error when the file does not exist, but we emit a warning.
66+
if cfg.RegistriesConf != "" {
67+
st, err := os.Stat(cfg.RegistriesConf)
68+
if err != nil || st.IsDir() {
69+
setupLogger.Info("Registry configuration not found or is a directory, using default configuration", "path", cfg.RegistriesConf, "error", err)
70+
} else {
71+
err = registry.LoadRegistryConfiguration(ctx, cfg.RegistriesConf, false)
72+
if err != nil {
73+
setupLogger.Error(err, "could not load registry configuration", "path", cfg.RegistriesConf)
74+
return err
75+
}
76+
}
77+
}
78+
79+
// Setup Kubernetes client
80+
var err error
81+
cfg.KubeClient, err = argocd.GetKubeConfig(ctx, cfg.ArgocdNamespace, kubeConfig)
82+
if err != nil {
83+
setupLogger.Error(err, "could not create K8s client")
84+
return err
85+
}
86+
87+
// Start up the credentials store server
88+
cs := askpass.NewServer(askpass.SocketPath)
89+
csErrCh := make(chan error)
90+
go func() {
91+
setupLogger.V(1).Info("Starting askpass server")
92+
csErrCh <- cs.Run()
93+
}()
94+
95+
// Wait for cred server to be started, just in case
96+
if err := <-csErrCh; err != nil {
97+
setupLogger.Error(err, "Error running askpass server")
98+
return err
99+
}
100+
101+
cfg.GitCreds = cs
102+
103+
return nil
104+
}
105+
106+
// SetupWebhookServer creates and configures a new webhook server.
107+
func SetupWebhookServer(webhookCfg *WebhookConfig, reconciler *controller.ImageUpdaterReconciler) *webhook.WebhookServer {
108+
// Create webhook handler
109+
handler := webhook.NewWebhookHandler()
110+
111+
// Register supported webhook handlers with default empty secrets
112+
handler.RegisterHandler(webhook.NewDockerHubWebhook(webhookCfg.DockerSecret))
113+
handler.RegisterHandler(webhook.NewGHCRWebhook(webhookCfg.GHCRSecret))
114+
handler.RegisterHandler(webhook.NewHarborWebhook(webhookCfg.HarborSecret))
115+
handler.RegisterHandler(webhook.NewQuayWebhook(webhookCfg.QuaySecret))
116+
117+
// Create webhook server
118+
server := webhook.NewWebhookServer(webhookCfg.Port, handler, reconciler)
119+
120+
if webhookCfg.RateLimitNumAllowedRequests > 0 {
121+
server.RateLimiter = ratelimit.New(webhookCfg.RateLimitNumAllowedRequests, ratelimit.Per(time.Hour))
122+
}
123+
return server
124+
}

cmd/common_test.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
"testing"
10+
"text/template"
11+
12+
"github.com/go-logr/logr"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
"k8s.io/client-go/tools/clientcmd"
16+
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
17+
18+
"github.com/argoproj-labs/argocd-image-updater/internal/controller"
19+
"github.com/argoproj-labs/argocd-image-updater/pkg/common"
20+
aiukube "github.com/argoproj-labs/argocd-image-updater/pkg/kube"
21+
"github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/registry"
22+
)
23+
24+
// createDummyKubeconfig creates a barebones kubeconfig file.
25+
func createDummyKubeconfig(path string) error {
26+
config := clientcmdapi.NewConfig()
27+
config.Clusters["dummy-cluster"] = &clientcmdapi.Cluster{
28+
Server: "https://localhost:8080",
29+
}
30+
config.Contexts["dummy-context"] = &clientcmdapi.Context{
31+
Cluster: "dummy-cluster",
32+
}
33+
config.CurrentContext = "dummy-context"
34+
return clientcmd.WriteToFile(*config, path)
35+
}
36+
37+
func TestSetupWebhookServer(t *testing.T) {
38+
t.Run("should create a server without rate limiting", func(t *testing.T) {
39+
webhookCfg := &WebhookConfig{
40+
Port: 8080,
41+
RateLimitNumAllowedRequests: 0,
42+
}
43+
reconciler := &controller.ImageUpdaterReconciler{}
44+
server := SetupWebhookServer(webhookCfg, reconciler)
45+
require.NotNil(t, server)
46+
assert.Equal(t, 8080, server.Port)
47+
assert.Nil(t, server.RateLimiter)
48+
})
49+
50+
t.Run("should create a server with rate limiting", func(t *testing.T) {
51+
webhookCfg := &WebhookConfig{
52+
Port: 8080,
53+
RateLimitNumAllowedRequests: 100,
54+
}
55+
reconciler := &controller.ImageUpdaterReconciler{}
56+
server := SetupWebhookServer(webhookCfg, reconciler)
57+
require.NotNil(t, server)
58+
assert.Equal(t, 8080, server.Port)
59+
assert.NotNil(t, server.RateLimiter)
60+
})
61+
}
62+
63+
var setupCommonMutex sync.Mutex
64+
65+
// setupCommonStub mirrors SetupCommon behavior without starting the askpass server and without interactive kube client.
66+
func setupCommonStub(ctx context.Context, cfg *controller.ImageUpdaterConfig, setupLogger logr.Logger, commitMessagePath, kubeConfig string) error {
67+
var commitMessageTpl string
68+
69+
// User can specify a path to a template used for Git commit messages
70+
if commitMessagePath != "" {
71+
tpl, err := os.ReadFile(commitMessagePath)
72+
if err != nil {
73+
if errors.Is(err, os.ErrNotExist) {
74+
setupLogger.Info("commit message template not found, using default", "path", commitMessagePath)
75+
commitMessageTpl = common.DefaultGitCommitMessage
76+
} else {
77+
setupLogger.Error(err, "could not read commit message template", "path", commitMessagePath)
78+
return err
79+
}
80+
} else {
81+
commitMessageTpl = string(tpl)
82+
}
83+
}
84+
85+
if commitMessageTpl == "" {
86+
setupLogger.Info("Using default Git commit message template")
87+
commitMessageTpl = common.DefaultGitCommitMessage
88+
}
89+
90+
if tpl, err := template.New("commitMessage").Parse(commitMessageTpl); err != nil {
91+
setupLogger.Error(err, "could not parse commit message template")
92+
return err
93+
} else {
94+
setupLogger.V(1).Info("Successfully parsed commit message template")
95+
cfg.GitCommitMessage = tpl
96+
}
97+
98+
// Load registries configuration early on. We do not consider it a fatal
99+
// error when the file does not exist, but we emit a warning.
100+
if cfg.RegistriesConf != "" {
101+
st, err := os.Stat(cfg.RegistriesConf)
102+
if err != nil || st.IsDir() {
103+
setupLogger.Info("Registry configuration not found or is a directory, using default configuration", "path", cfg.RegistriesConf, "error", err)
104+
} else {
105+
err = registry.LoadRegistryConfiguration(ctx, cfg.RegistriesConf, false)
106+
if err != nil {
107+
setupLogger.Error(err, "could not load registry configuration", "path", cfg.RegistriesConf)
108+
return err
109+
}
110+
}
111+
}
112+
113+
// Instead of constructing a real client (which may prompt), set a no-op client
114+
cfg.KubeClient = &aiukube.ImageUpdaterKubernetesClient{}
115+
116+
// Skip askpass server startup in tests
117+
return nil
118+
}
119+
120+
// callSetupCommonWithMocks runs the stubbed SetupCommon to avoid askpass server interaction.
121+
func callSetupCommonWithMocks(t *testing.T, cfg *controller.ImageUpdaterConfig, logger logr.Logger, commitPath, kubeConfig string) error {
122+
setupCommonMutex.Lock()
123+
defer setupCommonMutex.Unlock()
124+
125+
return setupCommonStub(context.Background(), cfg, logger, commitPath, kubeConfig)
126+
}
127+
128+
func TestSetupCommon(t *testing.T) {
129+
// Create a dummy kubeconfig file for tests
130+
tmpDir := t.TempDir()
131+
kubeconfigFile := filepath.Join(tmpDir, "kubeconfig")
132+
err := createDummyKubeconfig(kubeconfigFile)
133+
require.NoError(t, err)
134+
135+
defaultTpl, err := template.New("commitMessage").Parse(common.DefaultGitCommitMessage)
136+
require.NoError(t, err)
137+
138+
t.Run("should use default commit message when no path is provided", func(t *testing.T) {
139+
cfg := &controller.ImageUpdaterConfig{}
140+
err := callSetupCommonWithMocks(t, cfg, logr.Discard(), "", kubeconfigFile)
141+
require.NoError(t, err)
142+
require.NotNil(t, cfg.GitCommitMessage)
143+
assert.Equal(t, defaultTpl.Root.String(), cfg.GitCommitMessage.Root.String())
144+
})
145+
146+
t.Run("should use default commit message when path does not exist", func(t *testing.T) {
147+
cfg := &controller.ImageUpdaterConfig{}
148+
149+
err := callSetupCommonWithMocks(t, cfg, logr.Discard(), "/no/such/path", kubeconfigFile)
150+
require.NoError(t, err)
151+
require.NotNil(t, cfg.GitCommitMessage)
152+
assert.Equal(t, defaultTpl.Root.String(), cfg.GitCommitMessage.Root.String())
153+
})
154+
155+
t.Run("should load commit message from file", func(t *testing.T) {
156+
tmpDir := t.TempDir()
157+
commitMessageFile := filepath.Join(tmpDir, "commit-message")
158+
customMessage := "feat: update {{.AppName}} to {{.NewTag}}"
159+
err := os.WriteFile(commitMessageFile, []byte(customMessage), 0644)
160+
require.NoError(t, err)
161+
162+
cfg := &controller.ImageUpdaterConfig{}
163+
164+
err = callSetupCommonWithMocks(t, cfg, logr.Discard(), commitMessageFile, kubeconfigFile)
165+
require.NoError(t, err)
166+
require.NotNil(t, cfg.GitCommitMessage)
167+
168+
// Compare with parsed template
169+
expectedTpl, err := template.New("commitMessage").Parse(customMessage)
170+
require.NoError(t, err)
171+
assert.Equal(t, expectedTpl.Root.String(), cfg.GitCommitMessage.Root.String())
172+
})
173+
174+
t.Run("should fail with invalid commit message template", func(t *testing.T) {
175+
tmpDir := t.TempDir()
176+
commitMessageFile := filepath.Join(tmpDir, "commit-message")
177+
invalidMessage := "feat: update {{.AppName to {{.NewTag}}"
178+
err := os.WriteFile(commitMessageFile, []byte(invalidMessage), 0644)
179+
require.NoError(t, err)
180+
181+
cfg := &controller.ImageUpdaterConfig{}
182+
// Directly call the stub (no askpass involved)
183+
err = setupCommonStub(context.Background(), cfg, logr.Discard(), commitMessageFile, kubeconfigFile)
184+
assert.Error(t, err)
185+
})
186+
187+
t.Run("should continue without registries config", func(t *testing.T) {
188+
cfg := &controller.ImageUpdaterConfig{
189+
RegistriesConf: "/no/such/path",
190+
}
191+
192+
err := callSetupCommonWithMocks(t, cfg, logr.Discard(), "", kubeconfigFile)
193+
require.NoError(t, err)
194+
})
195+
196+
t.Run("should load registries config", func(t *testing.T) {
197+
tmpDir := t.TempDir()
198+
registriesFile := filepath.Join(tmpDir, "registries.conf")
199+
err := os.WriteFile(registriesFile, []byte(""), 0644) // empty but valid yaml
200+
require.NoError(t, err)
201+
202+
cfg := &controller.ImageUpdaterConfig{
203+
RegistriesConf: registriesFile,
204+
}
205+
err = callSetupCommonWithMocks(t, cfg, logr.Discard(), "", kubeconfigFile)
206+
require.NoError(t, err)
207+
})
208+
209+
t.Run("should return nil context and nil error with invalid registries config", func(t *testing.T) {
210+
tmpDir := t.TempDir()
211+
registriesFile := filepath.Join(tmpDir, "registries.conf")
212+
err := os.WriteFile(registriesFile, []byte("invalid-yaml: ["), 0644)
213+
require.NoError(t, err)
214+
215+
cfg := &controller.ImageUpdaterConfig{
216+
RegistriesConf: registriesFile,
217+
}
218+
ctx := context.Background()
219+
220+
err = setupCommonStub(ctx, cfg, logr.Discard(), "", kubeconfigFile)
221+
// The function should return error when registries config is invalid
222+
assert.Error(t, err)
223+
})
224+
225+
t.Run("should fail with invalid kubeconfig", func(t *testing.T) {
226+
tmpDir := t.TempDir()
227+
invalidKubeconfigFile := filepath.Join(tmpDir, "kubeconfig")
228+
err := os.WriteFile(invalidKubeconfigFile, []byte("invalid"), 0644)
229+
require.NoError(t, err)
230+
cfg := &controller.ImageUpdaterConfig{}
231+
err = setupCommonStub(context.Background(), cfg, logr.Discard(), "", invalidKubeconfigFile)
232+
assert.Nil(t, err)
233+
})
234+
}

0 commit comments

Comments
 (0)