Skip to content
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
47 changes: 47 additions & 0 deletions .github/workflows/build-deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: build ocifit-k8s

on:

# Publish packages on release
release:
types: [published]
pull_request: []

# On push to main we build and deploy images
push:
branches:
- main

jobs:
build:
permissions:
packages: write

runs-on: ubuntu-latest
name: Build
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Build Container
if: (github.event_name != 'release')
run: make

- name: GHCR Login
if: (github.event_name != 'pull_request')
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build Container
if: (github.event_name == 'release')
run: |
tag=${GITHUB_REF#refs/tags/}
make IMAGE_TAG=$tag
make push IMAGE_TAG=$tag

- name: Deploy
if: (github.event_name == 'push')
run: make push
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@

# OCIFit

<p align="center">
<img src="docs/ocifit-k8s.png" alt="OCIFit Kubernetes">
</p>
Expand Down
96 changes: 13 additions & 83 deletions cmd/ocifit/main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// main.go
package main

import (
Expand All @@ -19,7 +18,6 @@ import (

"ghcr.io/compspec/ocifit-k8s/pkg/artifact"
"ghcr.io/compspec/ocifit-k8s/pkg/validator"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
admissionv1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand All @@ -31,8 +29,6 @@ import (
v1listers "k8s.io/client-go/listers/core/v1"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/clientcmd"
"oras.land/oras-go/v2/content"
"oras.land/oras-go/v2/registry/remote"
)

const (
Expand Down Expand Up @@ -108,7 +104,6 @@ func (ws *WebhookServer) recalculateHomogeneity() {
// We can't include control plane nodes - they don't have NFD labels
workerNodeSelector, err := labels.Parse("!node-role.kubernetes.io/control-plane")
if err != nil {
// This is a static string, so this failure is fatal for the controller's logic.
log.Fatalf("FATAL: Failed to parse worker node selector: %v", err)
}

Expand Down Expand Up @@ -148,82 +143,17 @@ func (ws *WebhookServer) recalculateHomogeneity() {
ws.commonLabels = referenceLabels
}

// --- WebhookServer with Node Cache ---
// WebhookServer with Node Cache
type WebhookServer struct {
nodeLister v1listers.NodeLister
server *http.Server

// Cached state and a lock to protect it ---
// Cached state and a lock to protect it
stateLock sync.RWMutex
isHomogenous bool
commonLabels map[string]string
}

// findCompatibleImage uses ORAS to find an image that matches the requirements
func findCompatibleImage(ctx context.Context, imageRef string, requirements map[string]string) (string, error) {
registryName, repoAndTag, found := strings.Cut(imageRef, "/")
if !found {
return "", fmt.Errorf("invalid image reference format: %s", imageRef)
}
repoName, tag, found := strings.Cut(repoAndTag, ":")
if !found {
tag = "latest" // Default tag
}

// 1. Connect to the remote registry
reg, err := remote.NewRegistry(registryName)
if err != nil {
return "", fmt.Errorf("failed to connect to registry %s: %w", registryName, err)
}
repo, err := reg.Repository(ctx, repoName)
if err != nil {
return "", fmt.Errorf("failed to access repository %s: %w", repoName, err)
}

// 2. Resolve the image index descriptor by its tag
indexDesc, err := repo.Resolve(ctx, tag)
if err != nil {
return "", fmt.Errorf("failed to resolve image index %s:%s: %w", repoName, tag, err)
}

// 3. Fetch and unmarshal the image index
indexBytes, err := content.FetchAll(ctx, repo, indexDesc)
if err != nil {
return "", fmt.Errorf("failed to fetch image index content: %w", err)
}
var index ocispec.Index
if err := json.Unmarshal(indexBytes, &index); err != nil {
return "", fmt.Errorf("failed to unmarshal image index: %w", err)
}

log.Printf("Checking %d manifests in index for %s", len(index.Manifests), imageRef)

// 4. Iterate through manifests in the index to find a compatible one
for _, manifestDesc := range index.Manifests {
if manifestDesc.Annotations == nil {
continue
}

match := true
// Check if all pod requirements are met by the manifest's annotations
for reqKey, reqVal := range requirements {
if manifestVal, ok := manifestDesc.Annotations[reqKey]; !ok || manifestVal != reqVal {
match = false
break
}
}

if match {
// Found a compatible image!
finalImage := fmt.Sprintf("%s/%s@%s", registryName, repoName, manifestDesc.Digest)
log.Printf("Found compatible image: %s", finalImage)
return finalImage, nil
}
}

return "", fmt.Errorf("no compatible image found for requirements: %v", requirements)
}

// findMatchingNode searches the cache for a node that satisfies the pod's nodeSelector.
func (ws *WebhookServer) findMatchingNode(nodeSelector map[string]string) (*corev1.Node, error) {
if len(nodeSelector) == 0 {
Expand All @@ -246,8 +176,8 @@ func (ws *WebhookServer) findMatchingNode(nodeSelector map[string]string) (*core
}

// mutate is the core logic to look for compatibility labels and select a new image
// mutate is the core logic of our webhook. It uses a cached state for efficiency.
func (ws *WebhookServer) mutate(ar *admissionv1.AdmissionReview) *admissionv1.AdmissionResponse {

// Decode the Pod from the AdmissionReview
pod := &corev1.Pod{}
if err := json.Unmarshal(ar.Request.Object.Raw, pod); err != nil {
Expand All @@ -273,7 +203,7 @@ func (ws *WebhookServer) mutate(ar *admissionv1.AdmissionReview) *admissionv1.Ad
targetRef = targetRefDefault
}

// 2. Determine the target node's labels. We either have a homogenous cluster
// Determine the target node's labels. We either have a homogenous cluster
// (all nodes are the same) or we have to use a node selector for the image.
var nodeLabels map[string]string
if len(pod.Spec.NodeSelector) > 0 {
Expand All @@ -292,24 +222,24 @@ func (ws *WebhookServer) mutate(ar *admissionv1.AdmissionReview) *admissionv1.Ad
nodeLabels = commonLabels
}

// 3. Download and parse the compatibility spec from the OCI registry.
// Download and parse the compatibility spec from the OCI registry.
ctx := context.Background()

// Download the artifact (compatibility spec) from the uri
// TODO we should have mode to cache these and not need to re-download
// TODO (vsoch) we should have mode to cache these and not need to re-download
spec, err := artifact.DownloadCompatibilityArtifact(ctx, imageRef)
if err != nil {
return deny(ar, fmt.Sprintf("compatibility spec %s issue: %v", imageRef, err))
}

// 4. Evaluate the spec against the node's labels to find the winning tag.
// Evaluate the spec against the node's labels to find the winning tag.
// The "tag" attribute we are hijacking here to put the full container URI
finalImage, err := validator.EvaluateCompatibilitySpec(spec, nodeLabels)
if err != nil {
return deny(ar, fmt.Sprintf("failed to find compatible image: %v", err))
}

// 6. Create and apply the JSON patch (this logic is unchanged).
// Create and apply the JSON patch
var patches []JSONPatch
containerFound := false
for i, c := range pod.Spec.Containers {
Expand Down Expand Up @@ -375,7 +305,7 @@ func (ws *WebhookServer) handleMutate(w http.ResponseWriter, r *http.Request) {

func main() {

// --- Kubernetes Client and Informer Setup ---
// Kubernetes Client and Informer Setup
// We want to have a view of cluster nodes via NFD
config, err := clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG"))
if err != nil {
Expand Down Expand Up @@ -405,7 +335,7 @@ func main() {
ws.recalculateHomogeneity()
},
UpdateFunc: func(oldObj, newObj interface{}) {
// Optimization: only recalculate if compatibility labels have changed.
// Only recalculate if compatibility labels have changed.
oldNode := oldObj.(*corev1.Node)
newNode := newObj.(*corev1.Node)
if !reflect.DeepEqual(getCompatibilityLabels(oldNode), getCompatibilityLabels(newNode)) {
Expand All @@ -414,13 +344,13 @@ func main() {
},
})

// Start informer and wait for cache sync (same as before)
// Start informer and wait for cache sync
go factory.Start(stopCh)
if !cache.WaitForCacheSync(stopCh, nodeInformer.HasSynced) {
log.Fatal("failed to wait for caches to sync")
}

// --- NEW: Perform the initial calculation after cache sync ---
// Perform the initial calculation after cache sync
log.Println("Performing initial cluster homogeneity check...")
ws.recalculateHomogeneity()

Expand All @@ -446,7 +376,7 @@ func main() {
}
}()

// Graceful shutdown
// Graceful (or not so graceful) shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
Expand Down
Binary file added docs/ocifit-k8s-v1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/ocifit-k8s.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 5 additions & 6 deletions pkg/artifact/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
func DownloadCompatibilityArtifact(ctx context.Context, specRef string) (*types.CompatibilitySpec, error) {
log.Printf("Downloading compatibility spec from: %s", specRef)

// --- Step 1: Connect to the registry and resolve the manifest by its tag ---
// 1. Connect to the registry and resolve the manifest by its tag
reg, err := remote.NewRegistry(strings.Split(specRef, "/")[0])
if err != nil {
return nil, fmt.Errorf("failed to connect to registry for spec: %w", err)
Expand All @@ -31,7 +31,7 @@ func DownloadCompatibilityArtifact(ctx context.Context, specRef string) (*types.
return nil, fmt.Errorf("failed to resolve spec manifest %s: %w", specRef, err)
}

// --- Step 2: Fetch and parse the OCI Manifest itself ---
// 2. Fetch and parse the OCI Manifest itself
log.Println("Fetching OCI manifest content...")
manifestBytes, err := content.FetchAll(ctx, repo, manifestDesc)
if err != nil {
Expand All @@ -43,7 +43,7 @@ func DownloadCompatibilityArtifact(ctx context.Context, specRef string) (*types.
}
log.Printf("Successfully parsed OCI manifest (artifact type: %s)", manifest.ArtifactType)

// --- Step 3: Find the correct layer within the manifest ---
// 3. Find the correct layer within the manifest
log.Printf("Searching for spec layer with media type: %s", types.CompatibilitySpecMediaType)
var specLayerDesc *ocispec.Descriptor
for _, layer := range manifest.Layers {
Expand All @@ -59,14 +59,14 @@ func DownloadCompatibilityArtifact(ctx context.Context, specRef string) (*types.
}
log.Printf("Found spec layer with digest: %s", specLayerDesc.Digest)

// --- Step 4: Fetch the content of the spec layer using its descriptor ---
// 4. Fetch the content of the spec layer using its descriptor
log.Println("Fetching compatibility spec content...")
specBytes, err := content.FetchAll(ctx, repo, *specLayerDesc)
if err != nil {
return nil, fmt.Errorf("failed to fetch spec layer content: %w", err)
}

// --- Step 5: Unmarshal the final spec JSON into our struct ---
// 5. Unmarshal the final spec JSON into our struct
var spec types.CompatibilitySpec
err = json.Unmarshal(specBytes, &spec)
if err != nil {
Expand All @@ -75,5 +75,4 @@ func DownloadCompatibilityArtifact(ctx context.Context, specRef string) (*types.

log.Printf("Successfully downloaded and parsed spec version %s", spec.Version)
return &spec, nil

}
5 changes: 1 addition & 4 deletions pkg/types/types.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package types

// --- Image Compatibility Spec Structs (from NFD)
// Image Compatibility Spec Structs (from NFD)
// https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/api/image-compatibility/v1alpha1/spec.go

const CompatibilitySpecMediaType = "application/vnd.oci.image.compatibilities.v1+json"

// GroupRule is a list of node feature rules.
type GroupRule struct {
// CORRECTED: This is a slice to match the JSON array `[ ... ]`
MatchFeatures []FeatureMatcher `json:"matchFeatures"`
}

Expand Down Expand Up @@ -37,8 +36,6 @@ type MatchExpression struct {
Value []string `json:"value,omitempty"`
}

// --- Image Compatibility Spec Structs ---

// CompatibilitySpec represents image compatibility metadata.
type CompatibilitySpec struct {
Version string `json:"version"`
Expand Down
Loading