Skip to content

Commit 1134a5e

Browse files
authored
Merge pull request #2 from compspec/finish-webhook
feat: all is working
2 parents 5eb33c0 + f0481eb commit 1134a5e

File tree

10 files changed

+147
-43
lines changed

10 files changed

+147
-43
lines changed

README.md

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ This is a Kubernetes controller that will do the following:
1616
## Notes
1717

1818
For the above, you don't technically need NFD if you add and select your own labels, but I want to use NFD to better integrate with the larger
19-
community. The controller webhook will work with or without it, assuming the labels you have on nodes that you are selecting for exist.
19+
community. I've also scoped the matching labels to be for `feature.node` instead of `feature.node.kubernetes.io` so any generatd compatibility specs can be used for cases outside of Kubernetes (without the user thinking it is weird).
20+
The controller webhook will work with or without it, assuming the labels you have on nodes that you are selecting for exist.
2021
Next, we place this controller on the level of a webhook, meaning that it isn't influencing scheduling, but is anticipating what container
2122
should be selected for a node based on either:
2223

@@ -96,18 +97,18 @@ In our real world use case we would select based on operating system and kernel
9697
we are just going to add the same label to all nodes and then check our controller based on the image selected. Let's first add "vanilla":
9798

9899
```bash
99-
bash ./example/add-nfd-features.sh "compspec.ocifit-k8s.flavor=vanilla"
100+
bash ./example/add-nfd-features.sh "feature.node.ocifit-k8s.flavor=vanilla"
100101
```
101102

102103
View our added labels:
103104

104105
```bash
105-
$ kubectl get nodes -o json | jq .items[].metadata.labels | grep compspec.ocifit-k8s.flavor
106+
kubectl get nodes -o json | jq .items[].metadata.labels | grep feature.node.ocifit-k8s.flavor
106107
```
107108
```console
108-
"compspec.ocifit-k8s.flavor": "vanilla",
109-
"compspec.ocifit-k8s.flavor": "vanilla",
110-
"compspec.ocifit-k8s.flavor": "vanilla",
109+
"feature.node.ocifit-k8s.flavor": "vanilla",
110+
"feature.node.ocifit-k8s.flavor": "vanilla",
111+
"feature.node.ocifit-k8s.flavor": "vanilla",
111112
```
112113

113114
If you want to see actual NFD labels:
@@ -140,9 +141,9 @@ kubectl logs ocifit-k8s-deployment-68d5bf5865-494mg -f
140141

141142
At this point, we want to test compatibility. This step is already done, but I'll show you how I designed the compatibility spec. The logic for this dummy case is the following:
142143

143-
1. If our custom label "compspec.ocifit-k8s.flavor" is vanilla, we want to choose a debian container.
144-
1. If our custom label "compspec.ocifit-k8s.flavor" is chocolate, we want to choose a ubuntu container.
145-
1. If our custom label "compspec.ocifit-k8s.flavor" is strawberry, we want to choose a rockylinux container.
144+
1. If our custom label "feature.node.ocifit-k8s.flavor" is vanilla, we want to choose a debian container.
145+
1. If our custom label "feature.node.ocifit-k8s.flavor" is chocolate, we want to choose a ubuntu container.
146+
1. If our custom label "feature.node.ocifit-k8s.flavor" is strawberry, we want to choose a rockylinux container.
146147

147148
Normally, we would attach a compatibility spec to an image, like [this](https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/docs/usage/image-compatibility.md#attach-the-artifact-to-the-image). But
148149
here we are flipping the logic a bit. We don't know the image, and instead we are directing the client to look directly at one artifact. Thus, the first step was to package the compatibility artifact and push to a registry (and make it public). I did that as follows (you don't need to do this):
@@ -151,9 +152,82 @@ here we are flipping the logic a bit. We don't know the image, and instead we ar
151152
oras push ghcr.io/compspec/ocifit-k8s-compatibility:kind-example ./example/compatibility-test.json:application/vnd.oci.image.compatibilities.v1+json
152153
```
153154

154-
We aren't going to be using any referrers API or linking this to an image. The target images are in the artifact.
155+
We aren't going to be using any referrers API or linking this to an image. The target images are in the artifact, and we get there directly from the associated manifest.
156+
Try creating the pod
155157

156-
**being written**
158+
```bash
159+
kubectl apply -f ./example/pod.yaml
160+
```
161+
162+
Now look the log, the selection was vanilla, so we matched to the debian image.
163+
164+
```console
165+
PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"
166+
NAME="Debian GNU/Linux"
167+
VERSION_ID="11"
168+
VERSION="11 (bullseye)"
169+
VERSION_CODENAME=bullseye
170+
ID=debian
171+
HOME_URL="https://www.debian.org/"
172+
SUPPORT_URL="https://www.debian.org/support"
173+
BUG_REPORT_URL="https://bugs.debian.org/"
174+
```
175+
176+
Because we matched the feature.node label and it was the best fit container. Now delete that label and install
177+
another:
178+
179+
```bash
180+
kubectl delete -f example/pod.yaml
181+
bash ./example/remove-nfd-features.sh
182+
bash ./example/add-nfd-features.sh "feature.node.ocifit-k8s.flavor=chocolate"
183+
```
184+
185+
Now we match to ubuntu 22.04
186+
187+
```console
188+
PRETTY_NAME="Ubuntu 24.04.2 LTS"
189+
NAME="Ubuntu"
190+
VERSION_ID="24.04"
191+
VERSION="24.04.2 LTS (Noble Numbat)"
192+
VERSION_CODENAME=noble
193+
ID=ubuntu
194+
ID_LIKE=debian
195+
HOME_URL="https://www.ubuntu.com/"
196+
SUPPORT_URL="https://help.ubuntu.com/"
197+
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
198+
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
199+
UBUNTU_CODENAME=noble
200+
LOGO=ubuntu-logo
201+
```
202+
203+
And finally, strawberry.
204+
205+
```bash
206+
kubectl delete -f example/pod.yaml
207+
bash ./example/remove-nfd-features.sh
208+
bash ./example/add-nfd-features.sh "feature.node.ocifit-k8s.flavor=strawberry"
209+
```
210+
```console
211+
NAME="Rocky Linux"
212+
VERSION="9.3 (Blue Onyx)"
213+
ID="rocky"
214+
ID_LIKE="rhel centos fedora"
215+
VERSION_ID="9.3"
216+
PLATFORM_ID="platform:el9"
217+
PRETTY_NAME="Rocky Linux 9.3 (Blue Onyx)"
218+
ANSI_COLOR="0;32"
219+
LOGO="fedora-logo-icon"
220+
CPE_NAME="cpe:/o:rocky:rocky:9::baseos"
221+
HOME_URL="https://rockylinux.org/"
222+
BUG_REPORT_URL="https://bugs.rockylinux.org/"
223+
SUPPORT_END="2032-05-31"
224+
ROCKY_SUPPORT_PRODUCT="Rocky-Linux-9"
225+
ROCKY_SUPPORT_PRODUCT_VERSION="9.3"
226+
REDHAT_SUPPORT_PRODUCT="Rocky Linux"
227+
REDHAT_SUPPORT_PRODUCT_VERSION="9.3"
228+
```
229+
230+
Boum! Conceptually, we are selecting a different image depending on the rules in the compatibility spec. Our node features were dummy, but they could be real attributes related to kernel, networking, etc.
157231

158232
## License
159233

cmd/ocifit/main.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,11 @@ func deny(ar *admissionv1.AdmissionReview, message string) *admissionv1.Admissio
7676
}
7777

7878
// Determine if label is for NFD
79-
// We use this to assess uniqueness
79+
// We use this to assess uniqueness (homogeneity of cluster)
8080
func isCompatibilityLabel(key string) bool {
81-
return strings.HasPrefix(key, "feature.node.kubernetes.io/") ||
81+
// Note that the full URI is feature.node.kubernetes.io/
82+
// I'm truncating to feature.node so the features aren't Kubernetes specific
83+
return strings.HasPrefix(key, "feature.node") ||
8284
key == "kubernetes.io/arch" ||
8385
key == "kubernetes.io/os"
8486
}
@@ -300,9 +302,6 @@ func (ws *WebhookServer) mutate(ar *admissionv1.AdmissionReview) *admissionv1.Ad
300302
return deny(ar, fmt.Sprintf("compatibility spec %s issue: %v", imageRef, err))
301303
}
302304

303-
// Debug printing for me :)
304-
fmt.Println(spec)
305-
306305
// 4. Evaluate the spec against the node's labels to find the winning tag.
307306
// The "tag" attribute we are hijacking here to put the full container URI
308307
finalImage, err := validator.EvaluateCompatibilitySpec(spec, nodeLabels)
@@ -314,7 +313,7 @@ func (ws *WebhookServer) mutate(ar *admissionv1.AdmissionReview) *admissionv1.Ad
314313
var patches []JSONPatch
315314
containerFound := false
316315
for i, c := range pod.Spec.Containers {
317-
if c.Name == targetRef {
316+
if c.Image == targetRef {
318317
patches = append(patches, JSONPatch{
319318
Op: "replace",
320319
Path: fmt.Sprintf("/spec/containers/%d/image", i),

example/add-nfd-features.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ set -oe pipefail
99
NFD_NAMESPACE="node-feature-discovery"
1010

1111
# This is just a prefix - we will add an arbitrary selector here
12-
FEATURE_LABEL=${1:-"compspec.ocifit-k8s.flavor=vanilla"}
12+
FEATURE_LABEL=${1:-"feature.node.ocifit-k8s.flavor=vanilla"}
1313
echo "Planning to add ${FEATURE_LABEL} to worker nodes..."
1414

1515
echo "Finding worker nodes (nodes without the control-plane role)..."

example/compatibility-test.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"matchExpressions": [
1212
{
1313
"op": "In",
14-
"key": "compspec.ocifit-k8s.flavor",
14+
"key": "feature.node.ocifit-k8s.flavor",
1515
"value": [
1616
"vanilla"
1717
]
@@ -32,7 +32,7 @@
3232
"matchExpressions": [
3333
{
3434
"op": "In",
35-
"key": "compspec.ocifit-k8s.flavor",
35+
"key": "feature.node.ocifit-k8s.flavor",
3636
"value": [
3737
"chocolate"
3838
]
@@ -53,7 +53,7 @@
5353
"matchExpressions": [
5454
{
5555
"op": "In",
56-
"key": "compspec.ocifit-k8s.flavor",
56+
"key": "feature.node.ocifit-k8s.flavor",
5757
"value": [
5858
"strawberry"
5959
]

example/pod.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
apiVersion: v1
22
kind: Pod
33
metadata:
4-
name: pod-1
4+
name: pod
55
labels:
66
oci.image.compatibilities.selection/enabled: "true"
77
annotations:
88
oci.image.compatibilities.selection/image-ref: "ghcr.io/compspec/ocifit-k8s-compatibility:kind-example"
99
spec:
1010
# The container uri should be placeholder:latest. If you want to change, add the annotation:
1111
# oci.image.compatibilities.selection/target-image: "myplaceholder"
12-
1312
containers:
1413
- name: app
1514
image: placeholder:latest
16-
command: ["sleep", "3600"]
15+
command: ["/bin/sh", "-c"]
16+
args:
17+
- "cat /etc/os-release"

example/remove-nfd-features.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
set -e
33

44
# List the label keys to remove, each followed by a minus sign.
5-
LABELS_TO_REMOVE="compspec.ocifit-k8s.flavor-"
5+
LABELS_TO_REMOVE="feature.node.ocifit-k8s.flavor-"
66

77
echo "Finding worker nodes..."
88
WORKER_NODES=$(kubectl get nodes -l '!node-role.kubernetes.io/control-plane' -o jsonpath='{.items[*].metadata.name}')

pkg/artifact/artifact.go

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"ghcr.io/compspec/ocifit-k8s/pkg/types"
11+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1112
"oras.land/oras-go/v2/content"
1213
"oras.land/oras-go/v2/registry/remote"
1314
)
@@ -16,37 +17,63 @@ import (
1617
func DownloadCompatibilityArtifact(ctx context.Context, specRef string) (*types.CompatibilitySpec, error) {
1718
log.Printf("Downloading compatibility spec from: %s", specRef)
1819

19-
// Use ORAS to fetch the artifact
20+
// --- Step 1: Connect to the registry and resolve the manifest by its tag ---
2021
reg, err := remote.NewRegistry(strings.Split(specRef, "/")[0])
2122
if err != nil {
2223
return nil, fmt.Errorf("failed to connect to registry for spec: %w", err)
2324
}
24-
25-
repoName := strings.SplitN(strings.TrimPrefix(specRef, reg.Reference.Host()), ":", 2)[0]
26-
repoName = strings.TrimPrefix(repoName, "/") // Clean up repo name
27-
tag := strings.Split(specRef, ":")[1]
28-
29-
repo, err := reg.Repository(ctx, repoName)
25+
repo, err := reg.Repository(ctx, strings.Trim(strings.SplitN(specRef, ":", 2)[0], "/"))
26+
if err != nil {
27+
return nil, fmt.Errorf("failed to access repository for spec: %w", err)
28+
}
29+
manifestDesc, err := repo.Resolve(ctx, strings.Split(specRef, ":")[1])
3030
if err != nil {
31-
return nil, fmt.Errorf("failed to access repository %s: %w", repoName, err)
31+
return nil, fmt.Errorf("failed to resolve spec manifest %s: %w", specRef, err)
3232
}
3333

34-
desc, err := repo.Resolve(ctx, tag)
34+
// --- Step 2: Fetch and parse the OCI Manifest itself ---
35+
log.Println("Fetching OCI manifest content...")
36+
manifestBytes, err := content.FetchAll(ctx, repo, manifestDesc)
3537
if err != nil {
36-
return nil, fmt.Errorf("failed to resolve spec artifact %s: %w", specRef, err)
38+
return nil, fmt.Errorf("failed to fetch manifest content: %w", err)
39+
}
40+
var manifest ocispec.Manifest
41+
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
42+
return nil, fmt.Errorf("failed to unmarshal OCI manifest: %w", err)
3743
}
44+
log.Printf("Successfully parsed OCI manifest (artifact type: %s)", manifest.ArtifactType)
3845

39-
specBytes, err := content.FetchAll(ctx, repo, desc)
46+
// --- Step 3: Find the correct layer within the manifest ---
47+
log.Printf("Searching for spec layer with media type: %s", types.CompatibilitySpecMediaType)
48+
var specLayerDesc *ocispec.Descriptor
49+
for _, layer := range manifest.Layers {
50+
if layer.MediaType == types.CompatibilitySpecMediaType {
51+
// Found it! Keep a pointer to this descriptor.
52+
specLayerDesc = &layer
53+
break
54+
}
55+
}
56+
57+
if specLayerDesc == nil {
58+
return nil, fmt.Errorf("manifest does not contain a layer with media type %s", types.CompatibilitySpecMediaType)
59+
}
60+
log.Printf("Found spec layer with digest: %s", specLayerDesc.Digest)
61+
62+
// --- Step 4: Fetch the content of the spec layer using its descriptor ---
63+
log.Println("Fetching compatibility spec content...")
64+
specBytes, err := content.FetchAll(ctx, repo, *specLayerDesc)
4065
if err != nil {
41-
return nil, fmt.Errorf("failed to fetch spec artifact content: %w", err)
66+
return nil, fmt.Errorf("failed to fetch spec layer content: %w", err)
4267
}
4368

44-
// Unmarshal the JSON into our struct
69+
// --- Step 5: Unmarshal the final spec JSON into our struct ---
4570
var spec types.CompatibilitySpec
46-
if err := json.Unmarshal(specBytes, &spec); err != nil {
71+
err = json.Unmarshal(specBytes, &spec)
72+
if err != nil {
4773
return nil, fmt.Errorf("failed to unmarshal compatibility spec JSON: %w", err)
4874
}
4975

5076
log.Printf("Successfully downloaded and parsed spec version %s", spec.Version)
5177
return &spec, nil
78+
5279
}

pkg/types/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package types
33
// --- Image Compatibility Spec Structs (from NFD)
44
// https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/api/image-compatibility/v1alpha1/spec.go
55

6+
const CompatibilitySpecMediaType = "application/vnd.oci.image.compatibilities.v1+json"
7+
68
// GroupRule is a list of node feature rules.
79
type GroupRule struct {
810
// CORRECTED: This is a slice to match the JSON array `[ ... ]`

pkg/validator/rule.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package validator
22

33
import (
4+
"fmt"
45
"log"
56
"regexp"
67
"strconv"
@@ -10,6 +11,7 @@ import (
1011

1112
func evaluateRule(expression types.MatchExpression, nodeLabels map[string]string) bool {
1213
// Get the value of the label from the node. The 'ok' boolean is crucial.
14+
fmt.Printf("Checking expression %s against node labels %s", expression, nodeLabels)
1315
nodeVal, ok := nodeLabels[expression.Key]
1416

1517
switch expression.Op {

pkg/validator/validator.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,19 @@ func EvaluateCompatibilitySpec(spec *types.CompatibilitySpec, nodeLabels map[str
2424
for _, groupRule := range comp.Rules {
2525
if !allRulesMatch {
2626
break
27-
} // Short-circuit if a previous rule failed
28-
27+
}
2928
for _, featureMatcher := range groupRule.MatchFeatures {
3029
if !allRulesMatch {
3130
break
32-
} // Short-circuit
31+
}
3332

3433
// Each FeatureMatcher has a slice of MatchExpressions. Loop through them.
3534
// All MatchExpressions must match (AND logic).
3635
for _, expression := range featureMatcher.MatchExpressions {
3736
if !evaluateRule(expression, nodeLabels) {
3837
// As soon as one expression fails, the entire 'Compatibility' set is invalid.
3938
allRulesMatch = false
40-
break // Exit the inner 'expression' loop.
39+
break
4140
}
4241
}
4342
}

0 commit comments

Comments
 (0)