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
14 changes: 13 additions & 1 deletion docs/meta.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ you who owns the value.
| Prefix | Owner and use | Keys |
|---|---|---|
| `cocoonset.cocoonstack.io/` | CocoonSet CRD group, pod selector labels, and CocoonSet-level fields the operator mirrors onto a managed pod | `name`, `role`, `slot`, `mode`, `image`, `os`, `storage`, `snapshot-policy`, `network`, `managed`, `force-pull`, `generation`, `hibernated-on-node` |
| `vm.cocoonstack.io/` | VM-instance metadata — observed runtime state plus the per-VM spec the operator hands to vk-cocoon | `id`, `name`, `ip`, `vnc-port`, `hibernate`, `restore-from-hibernate`, `keep-snapshot-on-delete`, `fork-from`, `clone-from-dir`, `conn-type`, `backend`, `no-direct-io`, `probe-port`, `lifecycle-state`, `lifecycle-observed-generation`, `lifecycle-state-message`, `snapshot-annotations` |
| `vm.cocoonstack.io/` | VM-instance metadata — observed runtime state plus the per-VM spec the operator hands to vk-cocoon | `id`, `name`, `ip`, `vnc-port`, `hibernate`, `restore-from-hibernate`, `keep-snapshot-on-delete`, `fork-from`, `clone-from-dir`, `conn-type`, `backend`, `no-direct-io`, `probe-port`, `lifecycle-state`, `lifecycle-observed-generation`, `lifecycle-state-message`, `snapshot-annotations`, `snapshot`, `snapshot-status` |
| `cocoonstack.io/` | Node labels vk-cocoon stamps on its virtual node and the operator selects on | `pool`, `snapshot-cpu-class` |

`meta.KindCocoonSet` and `meta.TolerationKey`
Expand Down Expand Up @@ -138,6 +138,18 @@ registry; the engine never interprets them. `meta.ReadSnapshotAnnotations`
rejects a malformed value and any key under the `cocoonstack.` prefix, which the
snapshot wire format reserves for its own annotations.

`meta.AnnotationSnapshotRequest` (`vm.cocoonstack.io/snapshot`) asks vk-cocoon
to push a live snapshot of the pod's running VM to a registry-relative
`repo[:tag]` (the tag defaults to `latest`). The VM pauses only for the save
and keeps running; the snapshot keeps its NICs, so it can serve as a clone
source. vk-cocoon serves a request once the VM is Ready and no hibernate is
asked for, and answers in `meta.AnnotationSnapshotStatus`
(`vm.cocoonstack.io/snapshot-status`), a JSON `meta.SnapshotStatus` whose `ref`
echoes the request and whose `state` is `pushed` or `failed`; a `failed` answer
carries a `message`. A ref is answered once, pushed or failed, and re-patching
an answered ref does nothing: every push, a retry included, uses a new tag, and
the client waits for a status carrying that ref.

## Lifecycle status

`meta.LifecycleStatus` is the typed contract for the annotation triple
Expand Down
2 changes: 2 additions & 0 deletions meta/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ const (
AnnotationLifecycleStateMessage = "vm.cocoonstack.io/lifecycle-state-message"
// AnnotationSnapshotAnnotations carries a JSON object of OCI annotations stamped on every snapshot pushed for the pod.
AnnotationSnapshotAnnotations = "vm.cocoonstack.io/snapshot-annotations"
AnnotationSnapshotRequest = "vm.cocoonstack.io/snapshot"
AnnotationSnapshotStatus = "vm.cocoonstack.io/snapshot-status"

// RoleMain identifies the main agent VM (slot 0).
RoleMain = "main"
Expand Down
28 changes: 27 additions & 1 deletion meta/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,25 @@ import (
corev1 "k8s.io/api/core/v1"
)

const reservedSnapshotAnnotationPrefix = "cocoonstack."
const (
SnapshotStatePushed SnapshotState = "pushed"
SnapshotStateFailed SnapshotState = "failed"

reservedSnapshotAnnotationPrefix = "cocoonstack."
)

type SnapshotState string

type SnapshotStatus struct {
Ref string `json:"ref"`
State SnapshotState `json:"state"`
Message string `json:"message,omitempty"`
}

func (s SnapshotStatus) Annotations() map[string]any {
raw, _ := json.Marshal(s)
return map[string]any{AnnotationSnapshotStatus: string(raw)}
}

// ReadSnapshotAnnotations reads the caller's OCI annotations for the pod's pushed snapshots; keys under the cocoonstack. prefix are reserved.
func ReadSnapshotAnnotations(pod *corev1.Pod) (map[string]string, error) {
Expand All @@ -27,3 +45,11 @@ func ReadSnapshotAnnotations(pod *corev1.Pod) (map[string]string, error) {
}
return annotations, nil
}

func ReadSnapshotStatus(pod *corev1.Pod) SnapshotStatus {
var s SnapshotStatus
if raw := pod.Annotations[AnnotationSnapshotStatus]; raw != "" && json.Unmarshal([]byte(raw), &s) != nil {
return SnapshotStatus{}
}
return s
}
22 changes: 22 additions & 0 deletions meta/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,25 @@ func TestReadSnapshotAnnotations(t *testing.T) {
})
}
}

func TestSnapshotStatusRoundTrip(t *testing.T) {
want := SnapshotStatus{Ref: "user-snapshots/u1/s1", State: SnapshotStateFailed, Message: "vm is not running"}
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}}
for k, v := range want.Annotations() {
pod.Annotations[k] = v.(string)
}
if got := ReadSnapshotStatus(pod); got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
}

func TestReadSnapshotStatusUnanswered(t *testing.T) {
for name, raw := range map[string]string{"absent": "", "malformed": `{"ref":`} {
t.Run(name, func(t *testing.T) {
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationSnapshotStatus: raw}}}
if got := ReadSnapshotStatus(pod); got.Ref != "" {
t.Errorf("ref = %q, want unanswered", got.Ref)
}
})
}
}
6 changes: 4 additions & 2 deletions snapshot/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
// PushOptions configures a snapshot push operation.
type PushOptions struct {
Name string
LocalName string
Tag string
BaseImage string // optional cocoonstack.snapshot.baseimage annotation
Source string
Expand Down Expand Up @@ -53,9 +54,10 @@ func (p *Pusher) Push(ctx context.Context, opts PushOptions) error {
p = &Pusher{Uploader: s.UploadSession(), Cocoon: p.Cocoon}
}

stream, wait, err := p.Cocoon.Export(ctx, opts.Name)
localName := cmp.Or(opts.LocalName, opts.Name)
stream, wait, err := p.Cocoon.Export(ctx, localName)
if err != nil {
return fmt.Errorf("cocoon export %s: %w", opts.Name, err)
return fmt.Errorf("cocoon export %s: %w", localName, err)
}

entries, readErr := p.readAndUploadEntries(ctx, opts, stream)
Expand Down
33 changes: 32 additions & 1 deletion snapshot/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,35 @@ func TestPushOmitsBaseImageAnnotationWhenEmpty(t *testing.T) {
}
}

func TestPushExportsLocalNameAndUploadsUnderName(t *testing.T) {
for _, tt := range []struct {
name string
localName string
wantExport string
}{
{name: "defaults to name", wantExport: "user/s1"},
{name: "local name", localName: "snap-myvm", wantExport: "snap-myvm"},
} {
t.Run(tt.name, func(t *testing.T) {
cocoon := &fakeCocoon{exportTar: buildExportTar(t, snapshotExportConfig{Name: "myvm"}, map[string][]byte{
"config.json": []byte(`{}`),
})}
uploader := newFakeUploader()
pusher := &Pusher{Uploader: uploader, Cocoon: cocoon}

if err := pusher.Push(t.Context(), PushOptions{Name: "user/s1", LocalName: tt.localName, Tag: "v2"}); err != nil {
t.Fatalf("Push: %v", err)
}
if len(cocoon.exported) != 1 || cocoon.exported[0] != tt.wantExport {
t.Errorf("exported %v, want [%s]", cocoon.exported, tt.wantExport)
}
if _, ok := uploader.manifests["user/s1:v2"]; !ok {
t.Error("no manifest at user/s1:v2")
}
})
}
}

func TestPushStampsCallerAnnotations(t *testing.T) {
cocoon := &fakeCocoon{exportTar: buildExportTar(t, snapshotExportConfig{Name: "myvm"}, map[string][]byte{
"config.json": []byte(`{}`),
Expand Down Expand Up @@ -529,9 +558,11 @@ func (f *fakeUploader) GetBlob(_ context.Context, _, digest string) (io.ReadClos

type fakeCocoon struct {
exportTar []byte
exported []string
}

func (f *fakeCocoon) Export(_ context.Context, _ string) (io.ReadCloser, func() error, error) {
func (f *fakeCocoon) Export(_ context.Context, name string) (io.ReadCloser, func() error, error) {
f.exported = append(f.exported, name)
return io.NopCloser(bytes.NewReader(f.exportTar)), func() error { return nil }, nil
}

Expand Down
Loading