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

Improve Docker Swarm and Compose support #56

Merged
merged 12 commits into from
Jan 14, 2025
18 changes: 12 additions & 6 deletions cmd/cupdate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,6 @@ func main() {
edges := subgraph.Edges()
nodes := subgraph.Nodes()

// TODO: Rewrite to be more generic (to include Docker?)
var namespaceNode *platform.Node

mappedNodes := make(map[string]models.GraphNode)
Expand All @@ -324,6 +323,9 @@ func main() {
Type: string(n.Kind()),
Name: n.Name(),
}
if node.Type() == "docker/"+docker.ResourceKindSwarmNamespace || node.Type() == "docker/"+docker.ResourceKindComposeProject {
namespaceNode = &node
}
case platform.ImageNode:
mappedNodes[node.ID()] = models.GraphNode{
Domain: "oci",
Expand All @@ -336,8 +338,8 @@ func main() {
}

tags := []string{}

// Set tags for resources
// TODO: Handle for docker as well?
if namespaceNode != nil {
children := edges[(*namespaceNode).ID()]
for childID, isParent := range children {
Expand All @@ -355,10 +357,14 @@ func main() {
}

if childNode != nil {
resource := (*childNode).(kubernetes.Resource)
kind := resource.Kind()
if kind.IsSupported() {
tags = append(tags, kubernetes.TagName(resource.Kind()))
switch resource := (*childNode).(type) {
case kubernetes.Resource:
kind := resource.Kind()
if kind.IsSupported() {
tags = append(tags, kubernetes.TagName(resource.Kind()))
}
case docker.Resource:
tags = append(tags, docker.TagName(resource.Kind()))
}
}
}
Expand Down
68 changes: 66 additions & 2 deletions internal/platform/docker/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,19 @@ func (p *Platform) Graph(ctx context.Context) (*graph.Graph[platform.Node], erro
continue
}

graph.InsertTree(
// Docker Swarm has a preference for digested names, even when started with
// a manifest referencing a tag, try to resolve the reference
if !reference.HasTag && reference.HasDigest {
r, _, ok := strings.Cut(container.Image, "@")
if ok {
ref, err := oci.ParseReference(r)
if err == nil {
reference = ref
}
}
}

tree := []platform.Node{
platform.ImageNode{
Reference: reference,
},
Expand All @@ -226,7 +238,58 @@ func (p *Platform) Graph(ctx context.Context) (*graph.Graph[platform.Node], erro
id: fmt.Sprintf("docker/containers/%s", container.ID),
name: container.Name(),
},
)
}

// Add graph nodes for Docker Swarm and Compose, if available
if container.Labels != nil {
if taskID, ok := container.Labels["com.docker.swarm.task.id"]; ok {
taskName, ok := container.Labels["com.docker.swarm.task.name"]
if !ok {
taskName = taskID
}

tree = append(tree, resource{
kind: ResourceKindSwarmTask,
id: fmt.Sprintf("docker/swarm/task/%s", taskID),
name: taskName,
})
}

if serviceID, ok := container.Labels["com.docker.swarm.service.id"]; ok {
serviceName, ok := container.Labels["com.docker.swarm.service.name"]
if !ok {
serviceName = serviceID
}

tree = append(tree, resource{
kind: ResourceKindSwarmService,
id: fmt.Sprintf("docker/swarm/service/%s", serviceID),
name: serviceName,
})
} else if service, ok := container.Labels["com.docker.compose.service"]; ok {
tree = append(tree, resource{
kind: ResourceKindComposeService,
id: fmt.Sprintf("docker/compose/service/%s", service),
name: service,
})
}

if namespace, ok := container.Labels["com.docker.stack.namespace"]; ok {
tree = append(tree, resource{
kind: ResourceKindSwarmNamespace,
id: fmt.Sprintf("docker/swarm/namespace/%s", namespace),
name: namespace,
})
} else if project, ok := container.Labels["com.docker.compose.project"]; ok {
tree = append(tree, resource{
kind: ResourceKindComposeProject,
id: fmt.Sprintf("docker/compose/project/%s", project),
name: project,
})
}
}

graph.InsertTree(tree...)
}

return graph, nil
Expand All @@ -237,6 +300,7 @@ type Container struct {
Names []string
Image string
ImageID string
Labels map[string]string

// ... other ignored fields
}
Expand Down
17 changes: 16 additions & 1 deletion internal/platform/docker/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import (
type ResourceKind string

const (
ResourceKindContainer = "container"
ResourceKindContainer = "container"
ResourceKindSwarmTask = "swarm/task"
ResourceKindSwarmService = "swarm/service"
ResourceKindSwarmNamespace = "swarm/namespace"
ResourceKindComposeProject = "compose/project"
ResourceKindComposeService = "compose/service"
)

type Resource interface {
Expand Down Expand Up @@ -49,6 +54,16 @@ func TagName(kind ResourceKind) string {
switch kind {
case ResourceKindContainer:
return "container"
case ResourceKindSwarmTask:
return "task"
case ResourceKindSwarmService:
return "service"
case ResourceKindSwarmNamespace:
return "namespace"
case ResourceKindComposeProject:
return "project"
case ResourceKindComposeService:
return "service"
default:
// Panic as missing entries would be a programming issue, not runtime
// bug
Expand Down
24 changes: 21 additions & 3 deletions internal/worker/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"github.com/AlexGustafsson/cupdate/internal/httputil"
"github.com/AlexGustafsson/cupdate/internal/models"
"github.com/AlexGustafsson/cupdate/internal/oci"
"github.com/AlexGustafsson/cupdate/internal/platform/docker"
"github.com/AlexGustafsson/cupdate/internal/platform/kubernetes"
"github.com/AlexGustafsson/cupdate/internal/semver"
"github.com/AlexGustafsson/cupdate/internal/store"
"github.com/AlexGustafsson/cupdate/internal/workflow/imageworkflow"
Expand Down Expand Up @@ -112,9 +114,9 @@ func (w *Worker) ProcessRawImage(ctx context.Context, reference oci.Reference) e
// Add some basic tags
if data.LatestReference != nil {
if data.ImageReference.String() == data.LatestReference.String() {
data.Tags = append(data.Tags, "up-to-date")
data.InsertTag("up-to-date")
} else {
data.Tags = append(data.Tags, "outdated")
data.InsertTag("outdated")
}

// Add tags based on version diff
Expand All @@ -123,13 +125,29 @@ func (w *Worker) ProcessRawImage(ctx context.Context, reference oci.Reference) e
if currentVersion != nil && currentVersionErr == nil && newVersion != nil && newVersionErr == nil {
diff := currentVersion.Diff(newVersion)
if diff != "" {
data.Tags = append(data.Tags, diff)
data.InsertTag(diff)
}

versionDiffSortable = semver.PackInt64(newVersion) - semver.PackInt64(currentVersion)
}
}

// Add Kubernetes namespace and Docker stack tags
for _, node := range image.Graph.Nodes {
switch node.Domain {
case "kubernetes":
if node.Type == kubernetes.ResourceKindCoreV1Namespace {
data.InsertTag("namespace:" + node.Name)
}
case "docker":
if node.Type == docker.ResourceKindSwarmNamespace {
data.InsertTag("namespace:" + node.Name)
} else if node.Type == docker.ResourceKindComposeProject {
data.InsertTag("project:" + node.Name)
}
}
}

result := models.Image{
Reference: data.ImageReference.String(),
Created: data.Created,
Expand Down
2 changes: 1 addition & 1 deletion web/components/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function Badge({
className,
...rest
}: Omit<HTMLAttributes<HTMLSpanElement>, 'color'> & BadgeProps): JSX.Element {
let backgroundColor = '#CC5889'
let backgroundColor = 'light-dark(#3A3FCE, #4349f0)'
if (typeof color === 'string') {
backgroundColor = color
} else if (color !== undefined) {
Expand Down
34 changes: 28 additions & 6 deletions web/components/ImageCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { JSX } from 'react'

import { NavLink } from 'react-router-dom'
import { TagsByName } from '../tags'
import { NavLink, useNavigate } from 'react-router-dom'
import { TagsByName, compareTags } from '../tags'
import { formatRelativeTimeTo } from '../time'
import { Badge } from './Badge'
import { ImageLogo } from './ImageLogo'
Expand Down Expand Up @@ -32,6 +32,8 @@ export function ImageCard({
tags,
className,
}: ImageCardProps & { className?: string }): JSX.Element {
const navigate = useNavigate()

return (
<div
className={`flex gap-x-4 p-4 md:p-6 bg-white dark:bg-[#1e1e1e] rounded-lg shadow ${className || ''}`}
Expand Down Expand Up @@ -89,17 +91,37 @@ export function ImageCard({
)}
<p className="text-sm mt-2">{description}</p>
<div className="flex flex-wrap gap-2 mt-4">
{tags.map((x) => (
<NavLink key={x} to={`/?tag=${encodeURIComponent(x)}`}>
{tags
.toSorted((a, b) => compareTags(a, b))
.map((x) => (
<Badge
key={x}
label={x}
color={TagsByName[x]?.color}
className="hover:opacity-90"
// It's illegal to nest anchors in HTML, so unfortunately we need
// to use onClick here
onClick={(e) => {
e.metaKey || e.ctrlKey
? openTab(`/?tag=${encodeURIComponent(x)}`)
: navigate(`/?tag=${encodeURIComponent(x)}`)
e.preventDefault()
}}
/>
</NavLink>
))}
))}
</div>
</div>
</div>
)
}

function openTab(target: string) {
// Using window.open with target _blank creates a new window on Safari, macOS
// so use this cross-platform solution instead
const a = document.createElement('a')

a.rel = 'noreferrer'
a.target = '_blank'
a.href = target
a.click()
}
69 changes: 40 additions & 29 deletions web/components/TagSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type JSX, type PropsWithChildren, useRef, useState } from 'react'

import type { Tag } from '../tags'
import { type Tag, compareTags } from '../tags'
import { Badge } from './Badge'

const IOS = [
Expand Down Expand Up @@ -39,11 +39,13 @@ export function TagSelect({
)
}
>
{tags.map((x) => (
<option key={x.name} value={x.name}>
{x.name}
</option>
))}
{tags
.toSorted((a, b) => compareTags(a.name, b.name))
.map((x) => (
<option key={x.name} value={x.name}>
{x.name}
</option>
))}
</select>
<svg
role="img"
Expand Down Expand Up @@ -96,29 +98,38 @@ export function TagSelect({
</svg>
{isOpen && (
<div className="absolute group-hover:visible -top-4 -left-4 p-2 z-50 text-black dark:text-[#dddddd]">
<div className="flex flex-col gap-y-2 py-2 px-3 pr-6 bg-white dark:bg-[#292929] border-solid border-[1px] border-[#d0d0d0]/95 dark:border-[#505050] rounded-lg w-max shadow">
{tags.map((x) => (
<label key={x.name} className="cursor-pointer">
<input
type="checkbox"
checked={filter.includes(x.name)}
onChange={(e) =>
onChange((current) =>
e.target.checked
? [...current, x.name]
: current.filter((y) => y !== x.name)
)
}
className="scale-125 cursor-pointer"
/>
<Badge
title={x.description}
label={x.name}
color={x.color}
className="ml-2"
/>
</label>
))}
<div className="flex max-h-64 overflow-y-auto flex-col gap-y-2 py-2 px-3 pr-6 bg-white dark:bg-[#292929] border-solid border-[1px] border-[#d0d0d0]/95 dark:border-[#505050] rounded-lg w-max shadow">
{tags
.toSorted((a, b) =>
compareTags(
a.name,
b.name,
filter.includes(a.name),
filter.includes(b.name)
)
)
.map((x) => (
<label key={x.name} className="cursor-pointer">
<input
type="checkbox"
checked={filter.includes(x.name)}
onChange={(e) =>
onChange((current) =>
e.target.checked
? [...current, x.name]
: current.filter((y) => y !== x.name)
)
}
className="scale-125 cursor-pointer"
/>
<Badge
title={x.description}
label={x.name}
color={x.color}
className="ml-2"
/>
</label>
))}
</div>
</div>
)}
Expand Down
5 changes: 5 additions & 0 deletions web/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ const titles: Record<string, Record<string, string | undefined> | undefined> = {
},
docker: {
container: 'Container',
'swarm/task': 'Task',
'swarm/service': 'Service',
'swarm/namespace': 'Namespace',
'compose/service': 'Service',
'compose/project': 'Project',
},
}

Expand Down
Loading
Loading