Skip to content

Commit

Permalink
Track time of release based on annotations
Browse files Browse the repository at this point in the history
Use OCI annotations to identify when image versions were released. Note
that not all images use these annotations, so successrate might vary.

Solves: #33
  • Loading branch information
AlexGustafsson committed Dec 30, 2024
1 parent 76a6f48 commit c648bfb
Show file tree
Hide file tree
Showing 16 changed files with 140 additions and 12 deletions.
6 changes: 6 additions & 0 deletions api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,17 @@ components:
properties:
reference:
type: string
created:
type: string
format: datetime
description:
type: string
lasestReference:
type: string
description: The latest available version on the same track
latestCreated:
type: string
format: datetime
tags:
type: array
items:
Expand Down
2 changes: 2 additions & 0 deletions internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ type PaginationMetadata struct {

type Image struct {
Reference string `json:"reference"`
Created *time.Time `json:"created,omitempty"`
LatestReference string `json:"latestReference,omitempty"`
LatestCreated *time.Time `json:"latestCreated,omitempty"`
VersionDiffSortable uint64 `json:"-"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags"`
Expand Down
17 changes: 17 additions & 0 deletions internal/oci/models.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package oci

import (
"strings"
"time"
)

type DockerDistributionManifestListV2 struct {
// 2
SchemaVersion int `json:"schemaVersion"`
Expand Down Expand Up @@ -85,6 +90,18 @@ func (a Annotations) Source() string {
return s
}

func (a Annotations) CreatedTime() time.Time {
s := a["org.opencontainers.image.created"]
if s == "" {
s = a["org.label-schema.build-date"]
}

// Golang's RFC3339 format requires a "T", but the spec allows a space
s = strings.Replace(s, " ", "T", 1)
time, _ := time.Parse(time.RFC3339, s)
return time
}

type TagsPage struct {
Name string `json:"name"`
Tags []string `json:"tags"`
Expand Down
18 changes: 18 additions & 0 deletions internal/oci/models_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package oci

import (
"testing"
"time"

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

func TestAnnotations(t *testing.T) {
annotations := Annotations{
"org.opencontainers.image.created": "2024-12-20 10:52:57+00:00",
}

expected := time.Date(2024, 12, 20, 10, 52, 57, 0, time.FixedZone("", 0))

assert.True(t, annotations.CreatedTime().Equal(expected))
}
2 changes: 2 additions & 0 deletions internal/store/createTablesIfNotExist.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ CREATE TABLE IF NOT EXISTS raw_images (

CREATE TABLE IF NOT EXISTS images (
reference TEXT PRIMARY KEY NOT NULL,
created DATETIME,
latestReference TEXT,
latestCreated DATETIME,
versionDiffSortable INT NOT NULL,
description TEXT NOT NULL,
lastModified DATETIME NOT NULL,
Expand Down
12 changes: 7 additions & 5 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,13 @@ func (s *Store) InsertImage(ctx context.Context, image *models.Image) error {
}

statement, err := tx.PrepareContext(ctx, `INSERT INTO images
(reference, latestReference, versionDiffSortable, description, lastModified, imageUrl)
(reference, created, latestReference, latestCreated, versionDiffSortable, description, lastModified, imageUrl)
VALUES
(?, ?, ?, ?, ?, ?)
(?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(reference) DO UPDATE SET
created=excluded.created,
latestReference=excluded.latestReference,
latestCreated=excluded.latestCreated,
versionDiffSortable=excluded.versionDiffSortable,
description=excluded.description,
lastModified=excluded.lastModified,
Expand All @@ -258,7 +260,7 @@ func (s *Store) InsertImage(ctx context.Context, image *models.Image) error {
if image.LatestReference != "" {
latestReference = &image.LatestReference
}
_, err = statement.ExecContext(ctx, image.Reference, latestReference, image.VersionDiffSortable, image.Description, image.LastModified, image.Image)
_, err = statement.ExecContext(ctx, image.Reference, image.Created, latestReference, image.LatestCreated, image.VersionDiffSortable, image.Description, image.LastModified, image.Image)
statement.Close()
if err != nil {
tx.Rollback()
Expand Down Expand Up @@ -388,7 +390,7 @@ func (s *Store) InsertImage(ctx context.Context, image *models.Image) error {

func (s *Store) GetImage(ctx context.Context, reference string) (*models.Image, error) {
statement, err := s.db.PrepareContext(ctx, `SELECT
reference, latestReference, versionDiffSortable, description, imageUrl, lastModified
reference, created, latestReference, latestCreated, versionDiffSortable, description, imageUrl, lastModified
FROM images WHERE reference = ?;`)
if err != nil {
return nil, err
Expand All @@ -409,7 +411,7 @@ func (s *Store) GetImage(ctx context.Context, reference string) (*models.Image,
}

var latestReference *string
err = res.Scan(&image.Reference, &latestReference, &image.VersionDiffSortable, &image.Description, &image.Image, &image.LastModified)
err = res.Scan(&image.Reference, &image.Created, &latestReference, &image.LatestCreated, &image.VersionDiffSortable, &image.Description, &image.Image, &image.LastModified)
res.Close()
if err != nil {
return nil, err
Expand Down
2 changes: 2 additions & 0 deletions internal/worker/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ func (w *Worker) ProcessRawImage(ctx context.Context, reference oci.Reference) e

result := models.Image{
Reference: data.ImageReference.String(),
Created: data.Created,
LatestReference: "",
LatestCreated: data.LatestCreated,
VersionDiffSortable: versionDiffSortable,
Description: data.Description,
Tags: data.Tags,
Expand Down
17 changes: 14 additions & 3 deletions internal/workflow/condition.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
package workflow

import "fmt"
import (
"fmt"
)

// Condition controls whether or not a job or step (or post step) should run.
// A condition can be either a [ConditionFunc], or a string which references a
// value retrievable using [GetValue].
type Condition = any

// Conditio
// ConditionFunc is an adapter to allow the use of ordinary functions as
// Conditions.
type ConditionFunc = func(ctx Context) (bool, error)

// Always will always run.
func Always(ctx Context) (bool, error) {
return true, nil
}

// ValueExists will evaluate to true if the given key has a value.
func ValueExists(key string) Condition {
return ConditionFunc(func(ctx Context) (bool, error) {
_, ok := GetAnyValue(ctx, key)
return ok, nil
})
}

// testCondition tests whether or not a condition is fulfilled.
func testCondition(ctx Context, condition Condition) (bool, error) {
conditionFunc, ok := condition.(ConditionFunc)
Expand All @@ -24,7 +35,7 @@ func testCondition(ctx Context, condition Condition) (bool, error) {

switch cond := condition.(type) {
case string:
// The condition was a reference to a
// The condition was a reference to a boolean
return GetValue[bool](ctx, cond)
default:
return false, fmt.Errorf("invalid condition type %T expected string or %T", condition, conditionFunc)
Expand Down
3 changes: 3 additions & 0 deletions internal/workflow/imageworkflow/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package imageworkflow
import (
"slices"
"sync"
"time"

"github.com/AlexGustafsson/cupdate/internal/models"
"github.com/AlexGustafsson/cupdate/internal/oci"
Expand All @@ -11,8 +12,10 @@ import (
type Data struct {
sync.Mutex
ImageReference oci.Reference
Created *time.Time
Image string
LatestReference *oci.Reference
LatestCreated *time.Time
Tags []string
Description string
FullDescription *models.ImageDescription
Expand Down
10 changes: 9 additions & 1 deletion internal/workflow/imageworkflow/getannotations.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package imageworkflow

import (
"errors"
"runtime"

"github.com/AlexGustafsson/cupdate/internal/oci"
Expand All @@ -18,7 +19,14 @@ func GetAnnotations() workflow.Step {
}

image, err := workflow.GetInput[oci.Reference](ctx, "reference", true)
if err != nil {
if errors.Is(err, workflow.ErrInvalidType) {
imageRef, err := workflow.GetInput[*oci.Reference](ctx, "reference", true)
if err != nil {
return nil, err
} else if imageRef != nil {
image = *imageRef
}
} else if err != nil {
return nil, err
}

Expand Down
11 changes: 10 additions & 1 deletion internal/workflow/imageworkflow/getmanifests.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package imageworkflow

import (
"errors"

"github.com/AlexGustafsson/cupdate/internal/oci"
"github.com/AlexGustafsson/cupdate/internal/workflow"
)
Expand All @@ -16,7 +18,14 @@ func GetManifests() workflow.Step {
}

image, err := workflow.GetInput[oci.Reference](ctx, "reference", true)
if err != nil {
if errors.Is(err, workflow.ErrInvalidType) {
imageRef, err := workflow.GetInput[*oci.Reference](ctx, "reference", true)
if err != nil {
return nil, err
} else if imageRef != nil {
image = *imageRef
}
} else if err != nil {
return nil, err
}

Expand Down
29 changes: 29 additions & 0 deletions internal/workflow/imageworkflow/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ func New(httpClient *httputil.Client, data *Data) workflow.Workflow {
WithID("latest").
With("registryClient", workflow.Ref{Key: "step.registry.client"}).
With("reference", data.ImageReference),
GetManifests().
WithID("latest-manifests").
WithCondition(workflow.ValueExists("step.latest.reference")).
With("registryClient", workflow.Ref{Key: "step.registry.client"}).
With("reference", workflow.Ref{Key: "step.latest.reference"}),
GetAnnotations().
WithID("latest-annotations").
WithCondition(workflow.ValueExists("step.latest.reference")).
With("registryClient", workflow.Ref{Key: "step.registry.client"}).
With("reference", workflow.Ref{Key: "step.latest.reference"}).
With("manifests", workflow.Ref{Key: "step.latest-manifests.manifests"}),
workflow.Run(func(ctx workflow.Context) (workflow.Command, error) {
domain, err := workflow.GetValue[string](ctx, "step.registry.domain")
if err != nil {
Expand All @@ -63,13 +74,31 @@ func New(httpClient *httputil.Client, data *Data) workflow.Workflow {
})
}

time := annotations.CreatedTime()
if !time.IsZero() {
data.Created = &time
}

reference, err := workflow.GetValue[*oci.Reference](ctx, "step.latest.reference")
if err != nil {
return nil, err
}

data.LatestReference = reference

latestAnnotations, err := workflow.GetValue[oci.Annotations](ctx, "step.latest-annotations.annotations")
if err != nil {
return nil, err
}
fmt.Println(annotations, latestAnnotations)

if latestAnnotations != nil {
time := latestAnnotations.CreatedTime()
if !time.IsZero() {
data.LatestCreated = &time
}
}

return nil, nil
}),
},
Expand Down
7 changes: 6 additions & 1 deletion internal/workflow/input.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package workflow

import (
"errors"
"fmt"
)

// Input is an input value to a step.
// A value can be either a [Ref], or any verbatim value.
type Input = any

var (
ErrInvalidType = errors.New("invalid type")
)

// GetInput returns a value or output in the ctx.
// If a value does not exist, the type's zero value is returned, with a nil
// error unless required is true.
Expand All @@ -25,7 +30,7 @@ func GetInput[T any](ctx Context, name string, required bool) (T, error) {
var ok bool
ret, ok = v.(T)
if !ok {
return ret, fmt.Errorf("invalid type %T for input %s of type %T", v, name, ret)
return ret, fmt.Errorf("%w: input %s of type %T cannot be retrieved as %T", ErrInvalidType, name, v, ret)
}

return ret, nil
Expand Down
3 changes: 3 additions & 0 deletions internal/workflow/step.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func (s Step) WithID(id string) Step {
}

func (s Step) WithCondition(condition Condition) Step {
if s.If != nil {
panic("step already has a condition")
}
s.If = condition
return s
}
Expand Down
2 changes: 2 additions & 0 deletions web/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export interface PaginationMetadata {

export interface Image {
reference: string
created?: string
latestReference?: string
latestCreated?: string
description?: string
tags: string[]
links: ImageLink[]
Expand Down
11 changes: 10 additions & 1 deletion web/pages/ImagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ export function ImagePage(): JSX.Element {
</>
)}
</div>
{/* Image release dates, if newer and available */}
{image.value.latestCreated && (
<p>
Last updated{' '}
<span title={new Date(image.value.latestCreated).toLocaleString()}>
{formatRelativeTimeTo(new Date(image.value.latestCreated))}
</span>
</p>
)}
{/* Image description, if available */}
{image.value.description && (
<p className="mt-2">{image.value.description}</p>
Expand Down Expand Up @@ -335,7 +344,7 @@ export function ImagePage(): JSX.Element {

<div className="flex justify-center gap-x-2 items-center">
<p>
Last updated{' '}
Last processed{' '}
<span title={new Date(image.value.lastModified).toLocaleString()}>
{formatRelativeTimeTo(new Date(image.value.lastModified))}
</span>
Expand Down

0 comments on commit c648bfb

Please sign in to comment.