diff --git a/.github/workflows/build-deploy.yaml b/.github/workflows/build-deploy.yaml new file mode 100644 index 0000000..d9a52b7 --- /dev/null +++ b/.github/workflows/build-deploy.yaml @@ -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 diff --git a/README.md b/README.md index 0c6a22f..8d766eb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ + +# OCIFit +

OCIFit Kubernetes

diff --git a/cmd/ocifit/main.go b/cmd/ocifit/main.go index 9e3111f..9c030fe 100644 --- a/cmd/ocifit/main.go +++ b/cmd/ocifit/main.go @@ -1,4 +1,3 @@ -// main.go package main import ( @@ -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" @@ -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 ( @@ -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) } @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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)) { @@ -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() @@ -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 diff --git a/docs/ocifit-k8s-v1.png b/docs/ocifit-k8s-v1.png new file mode 100644 index 0000000..31043f9 Binary files /dev/null and b/docs/ocifit-k8s-v1.png differ diff --git a/docs/ocifit-k8s.png b/docs/ocifit-k8s.png index 31043f9..19b2926 100644 Binary files a/docs/ocifit-k8s.png and b/docs/ocifit-k8s.png differ diff --git a/pkg/artifact/artifact.go b/pkg/artifact/artifact.go index 24f41e0..7ef25d0 100644 --- a/pkg/artifact/artifact.go +++ b/pkg/artifact/artifact.go @@ -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) @@ -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 { @@ -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 { @@ -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 { @@ -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 - } diff --git a/pkg/types/types.go b/pkg/types/types.go index 42ffdc6..20a7512 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -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"` } @@ -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"` diff --git a/pkg/validator/rule.go b/pkg/validator/rule.go index 0ff78fd..4ad22ac 100644 --- a/pkg/validator/rule.go +++ b/pkg/validator/rule.go @@ -10,7 +10,7 @@ import ( ) func evaluateRule(expression types.MatchExpression, nodeLabels map[string]string) bool { - // Get the value of the label from the node. The 'ok' boolean is crucial. + // Get the value of the label from the node fmt.Printf("Checking expression %s against node labels %s", expression, nodeLabels) nodeVal, ok := nodeLabels[expression.Key] @@ -33,25 +33,33 @@ func evaluateRule(expression types.MatchExpression, nodeLabels map[string]string return true // Found a match. } } - return false // Did not find a match in the list. + // If we get here, we did not find a match in the list. + return false case types.MatchOpNotIn: // Rule matches if the key does not exist, OR if it exists but its value - // is NOT in the spec's value list. + // is not in the spec's value list. if !ok { - return true // Key doesn't exist, so it can't be "In" the forbidden set. + + // Key doesn't exist, so it can't be "In" the forbidden set. + return true } for _, v := range expression.Value { + + // Found a forbidden match. if nodeVal == v { - return false // Found a forbidden match. + return false } } - return true // Did not find any forbidden values. + // Did not find any forbidden values. + return true case types.MatchOpInRegexp: // Rule matches if the key exists AND its value matches any of the provided regex patterns. if !ok { - return false // Key must exist to match a regex. + + // Key must exist to match a regex. + return false } for _, pattern := range expression.Value { re, err := regexp.Compile(pattern) @@ -61,16 +69,20 @@ func evaluateRule(expression types.MatchExpression, nodeLabels map[string]string log.Printf("WARN: Invalid regexp in compatibility spec: '%s'. Error: %v", pattern, err) continue } + // Found a regex match. if re.MatchString(nodeVal) { - return true // Found a regex match. + return true } } - return false // No patterns matched. + // No patterns matched. + return false case types.MatchOpGt, types.MatchOpLt: // Rule matches if the key exists AND its integer value is > or < the spec's value. if !ok { - return false // Key must exist for comparison. + + // Key must exist for comparison. + return false } if len(expression.Value) == 0 { log.Printf("WARN: Gt/Lt operator used with no value in spec for key '%s'", expression.Key) diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 8811c99..6fd04c1 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -7,19 +7,17 @@ import ( "ghcr.io/compspec/ocifit-k8s/pkg/types" ) -// evaluateCompatibilitySpec finds the best matching compatibility set from the spec -// by evaluating its rules against the node's labels. // evaluateCompatibilitySpec finds the best matching compatibility set from the spec // by evaluating its rules against the node's labels. func EvaluateCompatibilitySpec(spec *types.CompatibilitySpec, nodeLabels map[string]string) (string, error) { var bestMatch *types.Compatibility maxWeight := -1 - // Loop through each top-level 'Compatibility' set in the spec. + // Loop through each top-level Compatibility set in the spec. for i, comp := range spec.Compatibilities { allRulesMatch := true - // Each 'Compatibility' set can have multiple 'GroupRule's. + // Each Compatibility set can have multiple GroupRules // All GroupRules must match for the set to be considered a match (AND logic). for _, groupRule := range comp.Rules { if !allRulesMatch { @@ -34,7 +32,7 @@ func EvaluateCompatibilitySpec(spec *types.CompatibilitySpec, nodeLabels map[str // All MatchExpressions must match (AND logic). for _, expression := range featureMatcher.MatchExpressions { if !evaluateRule(expression, nodeLabels) { - // As soon as one expression fails, the entire 'Compatibility' set is invalid. + // As soon as one expression fails, the entire Compatibility set is invalid. allRulesMatch = false break } @@ -42,7 +40,7 @@ func EvaluateCompatibilitySpec(spec *types.CompatibilitySpec, nodeLabels map[str } } - // If, after checking all the rules for this 'Compatibility' set, allRulesMatch is still true... + // If after checking all the rules for this set, allRulesMatch is still true... if allRulesMatch { log.Printf("Found a matching compatibility set: '%s' (weight %d)", comp.Tag, comp.Weight) @@ -50,6 +48,7 @@ func EvaluateCompatibilitySpec(spec *types.CompatibilitySpec, nodeLabels map[str if comp.Weight > maxWeight { maxWeight = comp.Weight // Capture the pointer to the current item in the slice. + // Note I haven't thought of how I'd want to use weight yet bestMatch = &spec.Compatibilities[i] } }