Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Shared Go packages for [cocoonstack](https://github.com/cocoonstack) services.
## Overview

- `apis/v1` -- typed CocoonSet and CocoonHibernation CRD Go types and generated CRD YAML manifests
- `meta` -- shared CRD identifiers, annotation/label/toleration keys, VM naming helpers, the typed `VMSpec` / `VMRuntime` / `HibernateState` annotation contract, and pod-state helpers (`IsPodReady`, `IsPodTerminal`, `IsContainerRunning`, `IsWindowsPod`, `PodKey`, `PodNodePool`) every cocoon component shares
- `meta` -- shared CRD identifiers, annotation/label/toleration keys, VM naming helpers, the typed `VMSpec` / `VMRuntime` / `HibernateState` / `LifecycleStatus` annotation contract, and pod-state helpers (`IsPodReady`, `IsPodTerminal`, `IsContainerRunning`, `IsWindowsPod`, `PodKey`, `PodNodePool`) every cocoon component shares
- `k8s` -- Kubernetes client config bootstrap with the standard kubeconfig fallback chain, merge-patch helpers, env/duration/sleep helpers (`EnvOrDefault`, `EnvDuration`, `EnvBool`, `SleepCtx`), unstructured decoder, and TLS helpers (`LoadOrGenerateCert`, `GenerateSelfSignedCert`, `DetectNodeIP`)
- `k8s/admission` -- shared admission-webhook scaffolding (`Allow` / `Deny` responses, `Decode` / `Serve` request loop, RFC 6902 `JSONPatchOp` + `EscapeJSONPointer` helpers) used by `cocoon-webhook` and reusable by any future cocoonstack admission handler
- `auth` -- shared HMAC-signed session helpers (sign/verify cookies, random state generation) used by glance and epoch for SSO cookie management
Expand Down Expand Up @@ -55,8 +55,8 @@ All identifiers live under two cocoonstack.io subdomains:

| Prefix | Used for | Examples |
|---|---|---|
| `cocoonset.cocoonstack.io/` | CocoonSet CRD group, Pod selector labels, and CocoonSet-level fields the operator mirrors onto a managed Pod | `cocoonset.cocoonstack.io/v1`, `name`, `role`, `slot`, `mode`, `image`, `os`, `storage`, `snapshot-policy`, `network`, `managed`, `force-pull` |
| `vm.cocoonstack.io/` | VM-instance metadata — observed runtime state plus per-VM spec the operator hands to vk-cocoon | `id`, `name`, `ip`, `vnc-port`, `hibernate`, `fork-from`, `clone-from-dir`, `conn-type`, `backend`, `no-direct-io`, `probe-port` |
| `cocoonset.cocoonstack.io/` | CocoonSet CRD group, Pod selector labels, and CocoonSet-level fields the operator mirrors onto a managed Pod | `cocoonset.cocoonstack.io/v1`, `name`, `role`, `slot`, `mode`, `image`, `os`, `storage`, `snapshot-policy`, `network`, `managed`, `force-pull`, `generation` |
| `vm.cocoonstack.io/` | VM-instance metadata — observed runtime state plus per-VM spec the operator hands to vk-cocoon | `id`, `name`, `ip`, `vnc-port`, `hibernate`, `fork-from`, `clone-from-dir`, `conn-type`, `backend`, `no-direct-io`, `probe-port`, `lifecycle-state`, `lifecycle-observed-generation`, `lifecycle-state-message` |

For typed annotation access, prefer the `meta.VMSpec` / `meta.VMRuntime` / `meta.HibernateState` wrappers over raw map manipulation:

Expand Down Expand Up @@ -90,6 +90,18 @@ Two snapshot tag constants anchor the cross-component contract:

`meta.ShouldSnapshotVM(spec)` is the single shared decoder for the `SnapshotPolicy` / slot-index decision. vk-cocoon consults it on the producer side (should I push this VM?) and cocoon-operator on the GC side (should I delete this tag?) so the two cannot drift — under `main-only` both sides agree only slot-0 is touched.

`meta.LifecycleStatus` is the typed contract for the lifecycle-state annotation triple vk-cocoon writes (`state`, `observed-generation`, `message`):

```go
meta.LifecycleStatus{
State: meta.LifecycleStateReady,
ObservedGeneration: meta.ReadCocoonSetGeneration(pod),
Message: "",
}.Apply(pod) // or .PatchPayload() for an apiserver patch body
Comment thread
CMGS marked this conversation as resolved.
Outdated
```

cocoon-operator stamps the owning CocoonSet's `metadata.generation` onto the pod via `meta.StampCocoonSetGeneration` so vk-cocoon can echo it back as `lifecycle-observed-generation`. Counter-based completion lets clients tell "the operation I asked for finished" from "an older completion is still being reported", without depending on wall-clock skew.

### `k8s`

Use `k8s.LoadConfig()` to resolve cluster configuration from:
Expand All @@ -106,6 +118,7 @@ Other helpers in this package:
- `k8s.StatusMergePatch` / `k8s.AnnotationsMergePatch` -- merge-patch builders used by reconcilers that prefer the JSON merge-patch encoding over `client.MergeFrom`.
- `k8s.PatchStatus[T]` -- generic `client.MergeFrom` patch for the `/status` subresource; captures the pre-mutation snapshot via the kubebuilder-generated typed `DeepCopy()` so callers skip the boilerplate.
- `k8s.PatchHibernateState` -- pod-level hibernate annotation patch that short-circuits when the pod already carries the desired state, safe to call unconditionally in a reconcile loop.
- `k8s.PatchCocoonSetGeneration` -- stamps the owning CocoonSet's `metadata.generation` onto the pod for vk-cocoon to echo back as `lifecycle-observed-generation`; same short-circuit semantics as `PatchHibernateState`.
- `k8s.NewReadyCondition` / `k8s.ConditionTypeReady` -- canonical `Ready` condition constructor shared across every cocoon CRD status block, leaving `LastTransitionTime` zero so `apimeta.SetStatusCondition` preserves the existing timestamp on no-op updates.
- `k8s.DecodeUnstructured[T]` -- generic unstructured-to-typed converter.

Expand Down
14 changes: 14 additions & 0 deletions k8s/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package k8s

import (
"context"
"strconv"

corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -32,6 +33,19 @@ func PatchHibernateState(ctx context.Context, cli client.Client, pod *corev1.Pod
})
}

// PatchCocoonSetGeneration stamps the owning CocoonSet's metadata.generation
// onto the pod so vk-cocoon can read it back as lifecycle-observed-generation.
// Short-circuits when the annotation is already correct.
func PatchCocoonSetGeneration(ctx context.Context, cli client.Client, pod *corev1.Pod, generation int64) error {
want := strconv.FormatInt(generation, 10)
if pod.Annotations[meta.AnnotationCocoonSetGeneration] == want {
return nil
}
Comment thread
CMGS marked this conversation as resolved.
return patchMerge(ctx, cli, pod, func(p *corev1.Pod) {
meta.StampCocoonSetGeneration(p, generation)
})
}

// patchMerge applies mutate under a MergeFrom patch on the primary resource.
func patchMerge[T DeepCopyObject[T]](ctx context.Context, cli client.Client, obj T, mutate func(T)) error {
patch := client.MergeFrom(obj.DeepCopy())
Expand Down
35 changes: 33 additions & 2 deletions k8s/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestPatchHibernateStateShortCircuitsNoOp(t *testing.T) {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "ns"},
}
(meta.HibernateState(true)).Apply(pod)
meta.HibernateState(true).Apply(pod)
cli := newFakeClient(t, pod.DeepCopy())

// A second call with the same state should be a no-op: the fake
Expand Down Expand Up @@ -78,7 +78,7 @@ func TestPatchHibernateStateSetsAnnotation(t *testing.T) {

func TestPatchHibernateStateClearsAnnotation(t *testing.T) {
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "ns"}}
(meta.HibernateState(true)).Apply(pod)
meta.HibernateState(true).Apply(pod)
cli := newFakeClient(t, pod.DeepCopy())

if err := PatchHibernateState(t.Context(), cli, pod, false); err != nil {
Expand All @@ -93,3 +93,34 @@ func TestPatchHibernateStateClearsAnnotation(t *testing.T) {
t.Errorf("hibernate annotation should be cleared, got %v", got.Annotations)
}
}

func TestPatchCocoonSetGenerationWritesValue(t *testing.T) {
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "ns"}}
cli := newFakeClient(t, pod.DeepCopy())

if err := PatchCocoonSetGeneration(t.Context(), cli, pod, 42); err != nil {
t.Fatalf("PatchCocoonSetGeneration: %v", err)
}

var got corev1.Pod
if err := cli.Get(t.Context(), client.ObjectKey{Namespace: "ns", Name: "demo"}, &got); err != nil {
t.Fatalf("get: %v", err)
}
if got.Annotations[meta.AnnotationCocoonSetGeneration] != "42" {
t.Errorf("annotation = %q, want 42", got.Annotations[meta.AnnotationCocoonSetGeneration])
}
}

func TestPatchCocoonSetGenerationShortCircuitsNoOp(t *testing.T) {
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
Name: "demo", Namespace: "ns",
Annotations: map[string]string{meta.AnnotationCocoonSetGeneration: "7"},
}}
cli := newFakeClient(t, pod.DeepCopy())

// Identical generation must be a true no-op: the fake client would
// error on Patch with an empty body, and the guard prevents that.
if err := PatchCocoonSetGeneration(t.Context(), cli, pod, 7); err != nil {
t.Fatalf("no-op PatchCocoonSetGeneration: %v", err)
}
}
135 changes: 135 additions & 0 deletions meta/lifecycle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package meta

import (
"strconv"

corev1 "k8s.io/api/core/v1"
)

// LifecycleState is the typed contract for the lifecycle-state annotation
// vk-cocoon publishes on a Pod.
type LifecycleState string

const (
LifecycleStateCreating LifecycleState = "creating"
LifecycleStateReady LifecycleState = "ready"
LifecycleStateHibernating LifecycleState = "hibernating"
LifecycleStateHibernated LifecycleState = "hibernated"
LifecycleStateFailed LifecycleState = "failed"
)

// IsTerminal reports whether s is a state a client would wait for.
func (s LifecycleState) IsTerminal() bool {
switch s {
case LifecycleStateReady, LifecycleStateHibernated, LifecycleStateFailed:
return true
}
return false
}

// LifecycleStatus is the full triple (state, observed-generation, message).
// PatchPayload is the source of truth for what gets written; Apply
// consumes the same payload in-memory and Snapshot derives a comparison
// key from the same fields.
type LifecycleStatus struct {
State LifecycleState
ObservedGeneration int64
Message string
}

// PatchPayload returns the strategic-merge value map. nil entries
// instruct the apiserver to delete the key.
Comment thread
CMGS marked this conversation as resolved.
Outdated
func (s LifecycleStatus) PatchPayload() map[string]any {
annos := map[string]any{
AnnotationLifecycleState: string(s.State),
AnnotationLifecycleObservedGeneration: strconv.FormatInt(s.ObservedGeneration, 10),
}
if s.Message == "" {
annos[AnnotationLifecycleStateMessage] = nil
} else {
annos[AnnotationLifecycleStateMessage] = s.Message
}
return annos
}

// Apply writes PatchPayload into the pod's annotations, deleting keys
// whose payload value is nil. Empty message clears the annotation so a
// stale failure reason cannot tail into the next lifecycle.
func (s LifecycleStatus) Apply(pod *corev1.Pod) {
if pod == nil {
return
}
a := ensurePodAnnotations(pod)
for key, val := range s.PatchPayload() {
if val == nil {
delete(a, key)
continue
}
a[key] = val.(string)
}
}

// Snapshot returns a stable comparison key. NUL separator avoids
// collisions with arbitrary message contents.
func (s LifecycleStatus) Snapshot() string {
return string(s.State) + "\x00" + strconv.FormatInt(s.ObservedGeneration, 10) + "\x00" + s.Message
}

// ReadLifecycleStatus reads the triple from pod annotations.
func ReadLifecycleStatus(pod *corev1.Pod) LifecycleStatus {
if pod == nil {
return LifecycleStatus{}
}
return LifecycleStatus{
State: LifecycleState(pod.Annotations[AnnotationLifecycleState]),
ObservedGeneration: ReadLifecycleObservedGeneration(pod),
Message: pod.Annotations[AnnotationLifecycleStateMessage],
}
}

// ReadLifecycleState reads the lifecycle-state annotation, "" when missing.
func ReadLifecycleState(pod *corev1.Pod) LifecycleState {
if pod == nil {
return ""
}
return LifecycleState(pod.Annotations[AnnotationLifecycleState])
}

// ReadLifecycleObservedGeneration reads the observed-generation annotation.
// Missing or unparseable returns 0 — callers treat it as "not observed yet".
func ReadLifecycleObservedGeneration(pod *corev1.Pod) int64 {
return readInt64Annotation(pod, AnnotationLifecycleObservedGeneration)
}

// ReadCocoonSetGeneration reads the CocoonSet generation stamped by
// cocoon-operator. vk-cocoon writes it back as observed-generation —
// counter-based completion is not subject to wallclock skew.
func ReadCocoonSetGeneration(pod *corev1.Pod) int64 {
return readInt64Annotation(pod, AnnotationCocoonSetGeneration)
}

// StampCocoonSetGeneration writes the CocoonSet generation onto the pod.
func StampCocoonSetGeneration(pod *corev1.Pod, generation int64) {
if pod == nil {
return
}
a := ensurePodAnnotations(pod)
a[AnnotationCocoonSetGeneration] = strconv.FormatInt(generation, 10)
}

// readInt64Annotation parses an int64-valued annotation, returning 0
// when missing or unparseable.
func readInt64Annotation(pod *corev1.Pod, key string) int64 {
if pod == nil {
return 0
}
raw := pod.Annotations[key]
if raw == "" {
return 0
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return n
}
Loading