Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose outdated images via RSS #8

Merged
merged 1 commit into from
Dec 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ CUPDATE_K8S_HOST=http://localhost:8001
CUPDATE_PROCESSING_INTERVAL=20s
CUPDATE_PROCESSING_ITEMS=1
CUPDATE_PROCESSING_MIN_AGE=2s

CUPDATE_WEB_ADDRESS=http://localhost:5173
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Features:
- Auto-detect container images in Kubernetes and Docker (planned)
- Auto-detect the latest available container image versions
- UI for discovering updates
- Subscribe to updates via an RSS feed (planned)
- Subscribe to updates via an RSS feed
- Graphs image versions' dependants explaining why they're in use
- Vulnerability scanning
- APIs for custom integrations
Expand Down
9 changes: 9 additions & 0 deletions api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ paths:
'201':
description: Accepted

/feed.rss:
get:
summary: Get an RSS feed of outdated images.
respones:
'200':
description: RSS feed.
content:
application/rss+xml:

components:
schemas:
ImagePage:
Expand Down
4 changes: 3 additions & 1 deletion cmd/cupdate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ type Config struct {
} `envPrefix:"API_"`

Web struct {
Disabled bool `env:"DISABLED"`
Disabled bool `env:"DISABLED"`
Address string `env:"ADDRESS"`
} `envPrefix:"WEB_"`

Cache struct {
Expand Down Expand Up @@ -298,6 +299,7 @@ func main() {
mux := http.NewServeMux()

apiServer := api.NewServer(readStore, processQueue)
apiServer.WebAddress = config.Web.Address
mux.Handle("/api/v1/", apiServer)

if !config.Web.Disabled {
Expand Down
31 changes: 16 additions & 15 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@
Cupdate requires zero configuration, but is very configurable. Configuration is
done using environment variables.

| Environment variable | Description | Default |
| -------------------------- | ----------------------------------------------------------------------- | ---------------- |
| `LOG_LEVEL` | `debug`, `info`, `warn`, `error` | `info` |
| `API_ADDRESS` | The address to expose the API on. | `0.0.0.0` |
| `API_PORT` | The port to expose the API on. | `8080` |
| `WEB_DISABLED` | Whether or not to disable the web UI. | `false` |
| `CACHE_PATH` | A path to the boltdb file in which to store cache. | `cachev1.boltdb` |
| `CACHE_MAX_AGE` | The maximum age of cache entries. | `24h` |
| `DB_PATH` | A path to the sqlite file in which to store data. | `dbv1.sqlite` |
| `PROCESSING_INTERVAL` | The interval between worker runs. | `1h` |
| `PROCESSING_ITEMS` | The number of items (images) to process each worker run. | `10` |
| `PROCESSING_MIN_AGE` | The minimum age of an item (image) before being processed. | `72h` |
| `PROCESSING_TIMEOUT` | The maximum time one image may take to process before being terminated. | `2m` |
| `K8S_HOST` | The host of the Kubernetes API. For use with proxying. | Required. |
| `K8S_INCLUDE_OLD_REPLICAS` | Whether or not to include old replica sets when scraping. | `false` |
| Environment variable | Description | Default |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `LOG_LEVEL` | `debug`, `info`, `warn`, `error` | `info` |
| `API_ADDRESS` | The address to expose the API on. | `0.0.0.0` |
| `API_PORT` | The port to expose the API on. | `8080` |
| `WEB_DISABLED` | Whether or not to disable the web UI. | `false` |
| `WEB_ADDRESS` | The URL at which the UI is available (such as `https://example.com`). Used for RSS feeds, should generally not be set | Automatically resolved |
| `CACHE_PATH` | A path to the boltdb file in which to store cache. | `cachev1.boltdb` |
| `CACHE_MAX_AGE` | The maximum age of cache entries. | `24h` |
| `DB_PATH` | A path to the sqlite file in which to store data. | `dbv1.sqlite` |
| `PROCESSING_INTERVAL` | The interval between worker runs. | `1h` |
| `PROCESSING_ITEMS` | The number of items (images) to process each worker run. | `10` |
| `PROCESSING_MIN_AGE` | The minimum age of an item (image) before being processed. | `72h` |
| `PROCESSING_TIMEOUT` | The maximum time one image may take to process before being terminated. | `2m` |
| `K8S_HOST` | The host of the Kubernetes API. For use with proxying. | Required. |
| `K8S_INCLUDE_OLD_REPLICAS` | Whether or not to include old replica sets when scraping. | `false` |
78 changes: 78 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ package api

import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"

"github.com/AlexGustafsson/cupdate/internal/httputil"
"github.com/AlexGustafsson/cupdate/internal/registry/oci"
"github.com/AlexGustafsson/cupdate/internal/rss"
"github.com/AlexGustafsson/cupdate/internal/store"
)

Expand All @@ -19,6 +24,8 @@ var (
type Server struct {
api *store.Store
mux *http.ServeMux

WebAddress string
}

func NewServer(api *store.Store, processQueue chan<- oci.Reference) *Server {
Expand Down Expand Up @@ -135,6 +142,77 @@ func NewServer(api *store.Store, processQueue chan<- oci.Reference) *Server {
w.WriteHeader(http.StatusAccepted)
})

s.mux.HandleFunc("GET /api/v1/feed.rss", func(w http.ResponseWriter, r *http.Request) {
var requestURL *url.URL
var err error
if s.WebAddress == "" {
requestURL, err = httputil.ResolveRequestURL(r)
} else {
requestURL, err = url.Parse(s.WebAddress)
}
if err != nil {
s.handleGenericResponse(w, r, ErrBadRequest)
return
}

// TODO: When we support other sort properties (like latest release), sort
// by that
// TODO: We currently use the default count. IIRC, it's good practice in RSS
// to return just the latest ~20 items.
options := &store.ListImageOptions{
Tags: []string{"outdated"},
}

page, err := api.ListImages(r.Context(), options)
if err != nil {
s.handleGenericResponse(w, r, err)
return
}

items := make([]rss.Item, len(page.Images))
for i, image := range page.Images {
ref, err := oci.ParseReference(image.LatestReference)
if err != nil {
s.handleGenericResponse(w, r, err)
return
}

items[i] = rss.Item{
GUID: rss.NewDeterministicGUID(image.Reference),
// TODO: Use image update time instead
PubDate: rss.Time(image.LastModified),
Title: fmt.Sprintf("%s updated", ref.Name()),
Link: requestURL.Scheme + "://" + requestURL.Host + "/image?reference=" + url.QueryEscape(image.Reference),
Description: fmt.Sprintf("%s updated to %s", ref.Name(), ref.Version()),
}
}

feed := rss.Feed{
Version: "2.0",
Channels: []rss.Channel{
{
Title: "Cupdate",
Link: requestURL.Scheme + "://" + requestURL.Host,
Description: "Container images discovered by Cupdate",
Items: items,
},
},
}

w.Header().Set("Content-Type", "application/rss+xml")
w.WriteHeader(http.StatusOK)

encoder := xml.NewEncoder(w)
encoder.Indent("", "\t")

if _, err := w.Write([]byte(xml.Header)); err != nil {
return
}
if err := encoder.Encode(&feed); err != nil {
return
}
})

return s
}

Expand Down
32 changes: 32 additions & 0 deletions internal/httputil/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package httputil

import (
"net/http"
"net/url"
)

func ResolveRequestURL(r *http.Request) (*url.URL, error) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}

host := r.Host

if header := r.Header.Get("X-Forwarded-Host"); header != "" {
host = header
}

if header := r.Header.Get("X-Forwarded-Proto"); header != "" {
if header == "http" || header == "https" {
scheme = header
}
}

base, err := url.Parse(scheme + "://" + host)
if err != nil {
return nil, err
}

return base.ResolveReference(r.URL), nil
}
60 changes: 60 additions & 0 deletions internal/httputil/url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package httputil

import (
"net/http"
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolveRequestURL(t *testing.T) {
testCases := []struct {
Name string
Request *http.Request
Expected *url.URL
}{
{
Name: "localhost",
Request: &http.Request{
Host: "localhost:8080",
URL: &url.URL{
Path: "/api/v1/feed.rss",
},
Header: http.Header{},
},
Expected: &url.URL{
Scheme: "http",
Host: "localhost:8080",
Path: "/api/v1/feed.rss",
},
},
{
Name: "proxied",
Request: &http.Request{
Host: "localhost:8080",
URL: &url.URL{
Path: "/api/v1/feed.rss",
},
Header: http.Header{
"X-Forwarded-Host": []string{"example.com"},
"X-Forwarded-Proto": []string{"https"},
},
},
Expected: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/api/v1/feed.rss",
},
},
}

for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
actual, err := ResolveRequestURL(testCase.Request)
require.NoError(t, err)
assert.Equal(t, testCase.Expected, actual)
})
}
}
55 changes: 55 additions & 0 deletions internal/rss/rss.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package rss

import (
"encoding/xml"
"time"
)

var rfc2822 = "Mon, 02 Jan 2006 15:04:05 MST"

type Feed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`

Channels []Channel `xml:"channel"`
}

type Channel struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Items []Item `xml:"item"`
}

type Item struct {
XMLName xml.Name `xml:"item"`
GUID string `xml:"guid"`
PubDate Time `xml:"pubDate"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
}

// Time represents a RFC2822 time, as used by RSS.
type Time time.Time

func (t Time) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(time.Time(t).Format(rfc2822), start)
}

func (t *Time) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var value string
err := d.DecodeElement(&value, &start)
if err != nil {
return err
}

time, err := time.Parse(rfc2822, value)
if err != nil {
return err
}

*t = Time(time)
return nil
}
Loading
Loading