Skip to content

Commit 3cdfa12

Browse files
committed
feat(PVO11Y-4936): Add initial implementation of registry exporter
1 parent dc220cd commit 3cdfa12

File tree

4 files changed

+462
-12
lines changed

4 files changed

+462
-12
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
This will be documentation for registry monitoring exporter (monitoring for quay, images.paas, etc.), explanations of the modules, background information, and implementation details.
2+
3+
Still subject to name changes...
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
// Package main implements a registry exporter for Prometheus metrics.
2+
package main
3+
4+
import (
5+
"context"
6+
"encoding/base64"
7+
"encoding/json"
8+
"io"
9+
"log"
10+
"net/http"
11+
"os"
12+
"strings"
13+
14+
"gopkg.in/yaml.v3"
15+
16+
"github.com/docker/docker/api/types/build"
17+
"github.com/docker/docker/api/types/image"
18+
"github.com/docker/docker/api/types/registry"
19+
"github.com/docker/docker/client"
20+
21+
"github.com/prometheus/client_golang/prometheus"
22+
"github.com/prometheus/client_golang/prometheus/promhttp"
23+
24+
"github.com/docker/docker/pkg/archive"
25+
)
26+
27+
type Metrics struct {
28+
RegistryTestUp *prometheus.GaugeVec
29+
RegistryPullCount *prometheus.CounterVec
30+
RegistryTotalPullCount *prometheus.CounterVec
31+
RegistryPushCount *prometheus.CounterVec
32+
RegistryTotalPushCount *prometheus.CounterVec
33+
}
34+
35+
// InitMetrics initializes and registers Prometheus metrics.
36+
func InitMetrics(reg prometheus.Registerer) *Metrics {
37+
m := &Metrics{
38+
RegistryTestUp: prometheus.NewGaugeVec(
39+
prometheus.GaugeOpts{
40+
Name: "registry_test_up",
41+
Help: "A simple gauge to indicate if the registryType is accessible (1 for up).",
42+
},
43+
[]string{"registryType"},
44+
),
45+
RegistryPullCount: prometheus.NewCounterVec(
46+
prometheus.CounterOpts{
47+
Name: "registry_successful_pull_count",
48+
Help: "Total number of successful image pulls.",
49+
},
50+
[]string{"registryType"},
51+
),
52+
RegistryTotalPullCount: prometheus.NewCounterVec(
53+
prometheus.CounterOpts{
54+
Name: "registry_total_pull_count",
55+
Help: "Total number of image pulls.",
56+
},
57+
[]string{"registryType"},
58+
),
59+
RegistryPushCount: prometheus.NewCounterVec(
60+
prometheus.CounterOpts{
61+
Name: "registry_successful_push_count",
62+
Help: "Total number of successful image pushes.",
63+
},
64+
[]string{"registryType"},
65+
),
66+
RegistryTotalPushCount: prometheus.NewCounterVec(
67+
prometheus.CounterOpts{
68+
Name: "registry_total_push_count",
69+
Help: "Total number of image pushes.",
70+
},
71+
[]string{"registryType"},
72+
),
73+
}
74+
reg.MustRegister(m.RegistryTestUp)
75+
reg.MustRegister(m.RegistryPullCount)
76+
reg.MustRegister(m.RegistryTotalPullCount)
77+
reg.MustRegister(m.RegistryPushCount)
78+
reg.MustRegister(m.RegistryTotalPushCount)
79+
return m
80+
}
81+
82+
func DockerClient() (*client.Client, error) {
83+
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
84+
if err != nil {
85+
log.Printf("Docker client error: %v", err)
86+
return nil, err
87+
}
88+
// Shouldn't client authenticate here?
89+
return cli, nil
90+
}
91+
92+
var registryTypes = map[string]string{
93+
"quay.io": "quay.io/redhat-user-workloads/rh-ee-tbehal-tenant/test-component",
94+
"images.paas.redhat.com": "images.paas.redhat.com/o11y/todo",
95+
}
96+
97+
func ImagePullTest(metrics *Metrics, registryType string) {
98+
defer metrics.RegistryTotalPullCount.WithLabelValues(registryType).Inc()
99+
100+
imageName, ok := registryTypes[registryType]
101+
if !ok {
102+
log.Printf("Unknown registry type: %s", registryType)
103+
return
104+
}
105+
imageName += ":pull" // TODO: Add tag management
106+
log.Print("Starting Image Pull Test...")
107+
108+
cli, err := DockerClient()
109+
if err != nil {
110+
return
111+
}
112+
defer cli.Close()
113+
114+
out, err := cli.ImagePull(context.Background(), imageName, image.PullOptions{})
115+
if err != nil {
116+
log.Printf("Image pull failed: %v", err)
117+
return
118+
}
119+
defer out.Close()
120+
121+
if _, err = io.Copy(io.Discard, out); err != nil {
122+
log.Printf("Error reading image pull output: %v", err)
123+
return
124+
}
125+
126+
log.Print("Image pull successful.")
127+
metrics.RegistryPullCount.WithLabelValues(registryType).Inc()
128+
}
129+
130+
func CreateDockerfile() {
131+
dockerfileContent := `FROM busybox:glibc
132+
133+
# Add a build-time timestamp to force uniqueness and avoid layer caching
134+
ARG BUILD_TIMESTAMP
135+
ENV BUILD_TIMESTAMP=${BUILD_TIMESTAMP}
136+
137+
LABEL quay.expires-after="1m"
138+
139+
# Example: touch a file with the timestamp
140+
RUN echo "${BUILD_TIMESTAMP}" > /timestamp.txt
141+
`
142+
if err := os.WriteFile("Dockerfile", []byte(dockerfileContent), 0644); err != nil {
143+
log.Printf("Failed to create Dockerfile: %v", err)
144+
}
145+
}
146+
147+
// authBase64 is used to parse the "auth" field from docker config JSON.
148+
type authBase64 struct {
149+
Auth string `json:"auth"`
150+
}
151+
152+
// ConfigJSON represents the structure of docker config JSON.
153+
type ConfigJSON struct {
154+
Auths map[string]authBase64 `json:"auths"`
155+
}
156+
157+
// Secret represents a Kubernetes secret containing docker config.
158+
type Secret struct {
159+
Data map[string]string `yaml:"data"`
160+
}
161+
162+
func ImagePushTest(metrics *Metrics, registryType string) {
163+
defer metrics.RegistryTotalPushCount.WithLabelValues(registryType).Inc()
164+
165+
imageName, ok := registryTypes[registryType]
166+
if !ok {
167+
log.Printf("Unknown registry type: %s", registryType)
168+
return
169+
}
170+
imageName += ":push" // TODO: Add tag management
171+
log.Print("Starting Image Push Test...")
172+
173+
cli, err := DockerClient()
174+
if err != nil {
175+
return
176+
}
177+
defer cli.Close()
178+
179+
buildCtx, err := archive.TarWithOptions(".", &archive.TarOptions{
180+
IncludeFiles: []string{"Dockerfile"},
181+
})
182+
if err != nil {
183+
log.Printf("Failed to create build context: %v", err)
184+
return
185+
}
186+
187+
buildOptions := build.ImageBuildOptions{
188+
Tags: []string{imageName},
189+
NoCache: true,
190+
Remove: true,
191+
}
192+
193+
log.Print("Building image...")
194+
195+
buildResp, err := cli.ImageBuild(context.Background(), buildCtx, buildOptions)
196+
if err != nil {
197+
log.Printf("Image build failed: %v", err)
198+
return
199+
}
200+
defer buildResp.Body.Close()
201+
if _, err = io.Copy(io.Discard, buildResp.Body); err != nil {
202+
log.Printf("Error reading image build output: %v", err)
203+
return
204+
}
205+
206+
log.Print("Preparing to push image...")
207+
208+
// NOTE: Technically, we can use kubernetes client to get secret from cluster
209+
filePath := os.Getenv("DOCKER_CONFIG_JSON_PATH")
210+
if filePath == "" {
211+
log.Printf("DOCKER_CONFIG_JSON_PATH environment variable not set")
212+
return
213+
}
214+
215+
var secret Secret
216+
fileBytes, err := os.ReadFile(filePath)
217+
if err != nil {
218+
log.Printf("failed to read docker config file at %s: %v", filePath, err)
219+
return
220+
}
221+
222+
if err := yaml.Unmarshal(fileBytes, &secret); err != nil {
223+
log.Printf("failed to unmarshal docker config yaml: %v", err)
224+
return
225+
}
226+
227+
dockerConfigB64, ok := secret.Data[".dockerconfigjson"]
228+
if !ok {
229+
log.Printf(".dockerconfigjson not found in secret data")
230+
return
231+
}
232+
233+
dockerConfigJSONBytes, err := base64.StdEncoding.DecodeString(dockerConfigB64)
234+
if err != nil {
235+
log.Printf("failed to decode .dockerconfigjson: %v", err)
236+
return
237+
}
238+
239+
var configJSON ConfigJSON
240+
if err := json.Unmarshal(dockerConfigJSONBytes, &configJSON); err != nil {
241+
log.Printf("failed to unmarshal docker config JSON: %v", err)
242+
return
243+
}
244+
245+
// Extract the first auth token found in the file
246+
// Is there a better way for that? :thinking:
247+
var registryAuthToken string
248+
for _, auth := range configJSON.Auths {
249+
registryAuthToken = auth.Auth
250+
break
251+
}
252+
if registryAuthToken == "" {
253+
log.Printf("auth token not found in the docker config file")
254+
return
255+
}
256+
257+
decodedAuth, err := base64.StdEncoding.DecodeString(registryAuthToken)
258+
if err != nil {
259+
log.Printf("failed to decode registry auth token: %v", err)
260+
return
261+
}
262+
263+
decodedAuthParts := strings.SplitN(string(decodedAuth), ":", 2)
264+
if len(decodedAuthParts) != 2 {
265+
log.Printf("invalid registry auth token format")
266+
return
267+
}
268+
269+
authConfig := registry.AuthConfig{
270+
Username: decodedAuthParts[0],
271+
Password: decodedAuthParts[1],
272+
}
273+
274+
encodedJSON, err := json.Marshal(authConfig)
275+
if err != nil {
276+
log.Printf("failed to marshal authConfig: %v", err)
277+
return
278+
}
279+
authStr := base64.URLEncoding.EncodeToString(encodedJSON)
280+
281+
out, err := cli.ImagePush(context.Background(), imageName, image.PushOptions{
282+
RegistryAuth: authStr,
283+
})
284+
if err != nil {
285+
log.Printf("Image push failed: %v", err)
286+
return
287+
}
288+
defer out.Close()
289+
290+
if _, err = io.Copy(io.Discard, out); err != nil {
291+
log.Printf("Error reading image push output: %v", err)
292+
return
293+
}
294+
295+
log.Print("Image push successful.")
296+
metrics.RegistryPushCount.WithLabelValues(registryType).Inc()
297+
}
298+
299+
func ImageManifestTest(metrics *Metrics) {
300+
// TODO: Implement manifest test
301+
}
302+
303+
func main() {
304+
log.SetOutput(os.Stderr)
305+
306+
registryType := "quay.io"
307+
308+
reg := prometheus.NewRegistry()
309+
metrics := InitMetrics(reg)
310+
311+
metrics.RegistryTestUp.WithLabelValues(registryType).Set(1)
312+
313+
handler := promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
314+
315+
CreateDockerfile()
316+
// Should the client init here, do we need to always refresh?
317+
318+
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
319+
log.Println("Metrics endpoint hit, running tests...")
320+
go ImagePullTest(metrics, registryType)
321+
go ImagePushTest(metrics, registryType)
322+
handler.ServeHTTP(w, r)
323+
324+
// TODO: Figure out where to increment these
325+
// metrics.RegistryTotalPullCount.WithLabelValues(registryType).Inc()
326+
// metrics.RegistryTotalPushCount.WithLabelValues(registryType).Inc()
327+
})
328+
329+
log.Println("http://localhost:9101/metrics")
330+
log.Fatal(http.ListenAndServe(":9101", nil))
331+
}

0 commit comments

Comments
 (0)