Skip to content

feat(vm-import): add VMware VM import via Forklift - #1982

Closed
Matthieu ROBIN (matthieu-robin) wants to merge 63 commits into
cozystack:mainfrom
matthieu-robin:vmware-import
Closed

feat(vm-import): add VMware VM import via Forklift#1982
Matthieu ROBIN (matthieu-robin) wants to merge 63 commits into
cozystack:mainfrom
matthieu-robin:vmware-import

Conversation

@matthieu-robin

@matthieu-robin Matthieu ROBIN (matthieu-robin) commented Feb 4, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Integrate Forklift v2.11.5 as the KubeVirt-native migration toolkit and add a tenant-facing vm-import app to migrate virtual machines from VMware vSphere into Cozystack. VMs are imported through Forklift (virt-v2v guest conversion, or VDDK raw-copy) and then adopted as managed VMInstance / VMDisk resources by a dedicated vm-adoption-controller, so a migrated VM becomes a dashboard-managed workload in the user's own tenant. Cold and warm migrations and network/storage mapping are supported.

New packages

  • packages/system/forklift-operator/ — Forklift operator (CRDs + deployment); component images are pinned to immutable digests
  • packages/system/forklift/ — ForkliftController CR
  • packages/system/vm-adoption-controller/ — adopts Forklift-imported VMs as Cozystack VMInstances
  • packages/apps/vm-import/ — tenant-facing VM import application
  • packages/system/vm-import-rd/ — ApplicationDefinition for the dashboard (IaaS category)
  • PackageSources for the above plus the bundles/iaas.yaml wiring

Isolation

vm-import is tenant-facing, so cross-namespace targets are guarded on both the raw-copy chart render (Plan.spec.targetNamespace) and the adoption controller: a release inside a tenant-* namespace is confined to its own namespace and cannot direct an import at another tenant.

Test plan

  • helm unittest for vm-import, vm-disk, forklift-operator, vm-adoption-controller
  • go test for the adoption controller (the cross-tenant guard, plan/migration resolution, adoption gating)
  • Validated end-to-end on a live cluster: a VMware UEFI AlmaLinux 9 VM migrated and booted inside a tenant, on both the virt-v2v and VDDK raw-copy paths
feat(vm-import): add VMware vSphere VM import via Forklift, adopted as managed Cozystack VMInstances

Summary by CodeRabbit

  • New Features

    • VM import from VMware vSphere to KubeVirt via Forklift, with network/storage mapping, warm/cold migration, and automatic adoption into Cozystack (toggleable).
  • Documentation

    • Extensive user guides, examples (simple/advanced/monitoring), adoption guide, and an adoption helper script.
  • Tests

    • New end-to-end tests covering import workflows and adoption scenarios.
  • Packaging

    • New charts and package manifests to install VM import and adoption components.

@dosubot dosubot Bot added size/XL This PR changes 500-999 lines, ignoring generated files kind/feature Categorizes issue or PR as related to a new feature labels Feb 4, 2026
@coderabbitai

coderabbitai Bot commented Feb 4, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a vm-import application and vm-adoption-controller plus Forklift integration, Helm charts, RD registration, docs, examples, E2E tests, and minor vm-instance template/schema updates to support DataVolume naming for VMware→KubeVirt imports and automatic adoption into Cozystack.

Changes

Cohort / File(s) Summary
vm-import App (chart & config)
packages/apps/vm-import/Chart.yaml, packages/apps/vm-import/values.yaml, packages/apps/vm-import/values.schema.json, packages/apps/vm-import/.helmignore, packages/apps/vm-import/Makefile
New Helm chart, values and schema for VM import, build targets, and packaging ignores.
vm-import Templates & library
packages/apps/vm-import/templates/*, packages/apps/vm-import/charts/cozy-lib
Helm templates for Provider, Plan, Migration, NetworkMap, StorageMap, dashboard RBAC, WorkloadMonitor; references cozy-lib library.
vm-import Documentation, examples & scripts
packages/apps/vm-import/README.md, packages/apps/vm-import/ADOPTION_DESIGN.md, packages/apps/vm-import/docs/*, packages/apps/vm-import/docs/scripts/adopt-vm.sh, packages/apps/vm-import/docs/examples/*
Extensive user docs, design notes, examples (simple/advanced/monitoring), adoption guide, and an adopt-vm.sh helper script.
Resource Definition (RD) & packaging
packages/system/vm-import-rd/*, packages/system/vm-import-rd/cozyrds/vm-import.yaml
ApplicationDefinition and templates registering VMImport with dashboard metadata and OpenAPI schema.
vm-adoption-controller (system)
packages/system/vm-adoption-controller/Chart.yaml, .../Makefile, .../README.md, .../images/controller/*, .../templates/*, .../values.yaml
New Go controller (main.go), Dockerfile, module, Helm chart and manifests (SA, ClusterRole/Binding, Deployment), and values for running an automated adoption controller.
Platform package sources & bundles
packages/core/platform/sources/*, packages/core/platform/templates/bundles/iaas.yaml
Adds PackageSource entries for forklift, vm-adoption-controller, vm-import-application and includes them in the iaas bundle.
Forklift charts & system packages
packages/system/forklift-operator/*, packages/system/forklift/*, packages/system/vm-import-rd/*, packages/system/vm-import-rd/templates/cozyrd.yaml
New Helm chart metadata and Makefiles for forklift operator and forklift system packages; includes a prepared forklift CR.
E2E tests
hack/e2e-apps/vm-import.bats
Bats test suite covering full, minimal, and adoption-disabled VMImport scenarios with resource validations and cleanup.
vm-instance changes
packages/apps/vm-instance/templates/vm.yaml, packages/apps/vm-instance/values.schema.json, packages/apps/vm-instance/values.yaml
Optional disk field dvName added (schema/docs) and template adjusted to use dvName or default vm-disk- for DataVolume references.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant VMware as vSphere
    participant Forklift as Forklift<br/>Operator
    participant KubeVirt as KubeVirt
    participant Adoption as VM<br/>AdoptionController
    participant Cozystack as Cozystack<br/>Dashboard

    User->>KubeVirt: Create Secret (vCenter creds)
    User->>KubeVirt: Apply VMImport CR
    KubeVirt->>Forklift: Create Provider / Plan / Migration
    Forklift->>VMware: Connect & transfer VM disks
    Forklift->>KubeVirt: Create VirtualMachines & DataVolumes
    Forklift->>KubeVirt: Label VMs (plan reference)
    Adoption->>KubeVirt: Watch labeled VMs
    Adoption->>KubeVirt: Get Plan (check adoption enabled)
    alt adoption enabled
        Adoption->>KubeVirt: Create VMInstance CR
        Adoption->>KubeVirt: Label VM as adopted
        KubeVirt->>Cozystack: VMInstance sync -> HelmRelease
        Cozystack->>User: Show VM in dashboard
    else adoption disabled
        Adoption->>Adoption: Skip VM
    end
Loading
sequenceDiagram
    participant Forklift as Forklift
    participant Controller as VM Adoption<br/>Controller
    participant API as Kubernetes API
    participant VM as VirtualMachine

    Forklift->>VM: Create with forklift labels
    Forklift->>API: Annotate Plan (adoption enabled/disabled)
    loop reconcile (interval)
        Controller->>API: List VMs with plan label
        Controller->>API: Get Plan resource
        alt adoption enabled
            Controller->>API: Create VMInstance from VM spec
            rect rgba(76, 175, 80, 0.5)
            Note over Controller,API: extract state, disks, networks, SSH keys
            end
            Controller->>API: Patch VM with adopted labels
        else
            Note over Controller: skip adoption
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐇 I nibbled bytes from vSphere's vine,

Forklift ferried disks so fine,
I stitched them into Cozystack's lair,
Labeled and adopted with diligent care,
Hooray — migrations hopped right there!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(vm-import): add VMware VM import via Forklift' clearly and specifically describes the main change—adding VMware VM import functionality via Forklift integration.
Linked Issues check ✅ Passed The PR fully satisfies issue #250 objectives by delivering VMware-to-KubeVirt migration tooling with Forklift integration, network/storage mappings, and vm-adoption-controller for dashboard visibility.
Out of Scope Changes check ✅ Passed All changes align with PR objectives: vm-import app, Forklift operator/controller, vm-adoption-controller, dashboard integration, and vm-instance enhancements for dvName support are all in scope.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello Matthieu ROBIN (@matthieu-robin), I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the platform's virtualization capabilities by introducing a robust solution for migrating existing VMware virtual machines to KubeVirt. It provides a comprehensive application for users to manage the entire migration lifecycle, from source configuration to network and storage mapping, supporting various migration strategies. This integration streamlines the process of moving workloads from traditional VMware environments to a Kubernetes-native virtualization platform.

Highlights

  • Forklift Integration: Integrated Forklift v2.10.4 as the KubeVirt-native migration toolkit, enabling the import of Virtual Machines from VMware vSphere.
  • New VM Import Application: Introduced a new vm-import application package, providing a user-facing interface to configure VMware sources, select VMs for migration, and map networks and storage.
  • Migration Capabilities: Added support for both cold and warm migrations, including automatic guest conversion via virt-v2v for seamless integration with KubeVirt.
  • New Package Structure: Created several new packages: forklift-operator, forklift, vm-import, vm-import-rd, and corresponding PackageSource definitions for Forklift and the VM import application.
  • IaaS Bundle Update: Modified the iaas.yaml bundle to include the newly added Forklift and vm-import components, making them part of the Infrastructure as a Service offering.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • packages/apps/vm-import/.helmignore
    • Added a .helmignore file to specify patterns for files to ignore when packaging the Helm chart.
  • packages/apps/vm-import/Chart.yaml
    • Added the Helm chart definition for the vm-import application, including its name, description, icon, and cozy-lib dependency.
  • packages/apps/vm-import/Makefile
    • Added a Makefile for generating values schema and updating CRDs for the vm-import application.
  • packages/apps/vm-import/README.md
    • Added comprehensive documentation for the vm-import application, detailing usage instructions, credential creation, VM identification, migration monitoring, and parameter descriptions for VMware source, migration plan, network mapping, and storage mapping.
  • packages/apps/vm-import/charts/cozy-lib
    • Added a reference to the cozy-lib library within the vm-import application's charts directory.
  • packages/apps/vm-import/logos/vm-import.svg
    • Added an SVG icon for the vm-import application.
  • packages/apps/vm-import/templates/dashboard-resourcemap.yaml
    • Added RBAC definitions (Role and RoleBinding) to allow the dashboard to access Forklift resources like providers, plans, and migrations.
  • packages/apps/vm-import/templates/migration.yaml
    • Added a Helm template to create a Forklift Migration custom resource, conditionally based on the presence of VMs in the values.
  • packages/apps/vm-import/templates/networkmap.yaml
    • Added a Helm template to create a Forklift NetworkMap custom resource, enabling mapping of source VMware networks to destination Kubernetes pod or Multus networks.
  • packages/apps/vm-import/templates/plan.yaml
    • Added a Helm template to create a Forklift Plan custom resource, defining the migration strategy, source/destination providers, and VM list.
  • packages/apps/vm-import/templates/provider.yaml
    • Added a Helm template to create Forklift Provider custom resources for both VMware vSphere (source) and OpenShift/KubeVirt (destination).
  • packages/apps/vm-import/templates/storagemap.yaml
    • Added a Helm template to create a Forklift StorageMap custom resource, allowing mapping of source VMware datastores to destination Kubernetes StorageClasses.
  • packages/apps/vm-import/templates/workloadmonitor.yaml
    • Added a WorkloadMonitor resource definition for the vm-import application.
  • packages/apps/vm-import/values.schema.json
    • Added the JSON schema for the vm-import Helm chart's configurable values, including network and storage mappings, source details, and VM list.
  • packages/apps/vm-import/values.yaml
    • Added the default values file for the vm-import Helm chart, providing initial configurations and descriptions for all parameters.
  • packages/core/platform/sources/forklift.yaml
    • Added a PackageSource definition for the Forklift operator and controller, specifying its dependencies and installation details.
  • packages/core/platform/sources/vm-import-application.yaml
    • Added a PackageSource definition for the vm-import application, outlining its components, libraries, and dependencies.
  • packages/core/platform/templates/bundles/iaas.yaml
    • Modified the IaaS bundle template to include the new cozystack.forklift and cozystack.vm-import-application packages.
  • packages/system/forklift-operator/Chart.yaml
    • Added the Helm chart definition for the cozy-forklift-operator.
  • packages/system/forklift-operator/Makefile
    • Added a Makefile for the Forklift operator, including commands to update its manifests from the upstream Forklift repository.
  • packages/system/forklift/Chart.yaml
    • Added the Helm chart definition for the cozy-forklift controller.
  • packages/system/forklift/Makefile
    • Added a Makefile for the forklift controller package.
  • packages/system/forklift/templates/forklift-cr.yaml
    • Added a Helm template to create the ForkliftController custom resource, configuring various Forklift features.
  • packages/system/vm-import-rd/Chart.yaml
    • Added the Helm chart definition for the vm-import-rd (VM Import ApplicationDefinition).
  • packages/system/vm-import-rd/Makefile
    • Added a Makefile for the vm-import-rd package.
  • packages/system/vm-import-rd/cozyrds/vm-import.yaml
    • Added the ApplicationDefinition for vm-import, integrating it into the dashboard with its OpenAPI schema, category, and display properties.
  • packages/system/vm-import-rd/templates/cozyrd.yaml
    • Added a Helm template to include all ApplicationDefinition resources from the cozyrds directory.
  • packages/system/vm-import-rd/values.yaml
    • Added an empty default values file for the vm-import-rd Helm chart.
Activity
  • The pull request content was generated using Claude Code, indicating an AI-assisted development process.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the VMware VM import feature via Forklift, integrating it seamlessly into the existing CozyStack platform. The new packages for forklift-operator, forklift, vm-import, and vm-import-rd are well-structured and follow Helm best practices. The Makefile for forklift-operator includes robust logic for namespace handling and removal of kind: Namespace definitions, which is a good practice for Helm chart generation from upstream Kustomize. The README.md provides clear and comprehensive instructions for users, and the values.schema.json accurately defines the configuration parameters. Overall, the changes are well-implemented and align with the stated objectives of the pull request.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@packages/apps/vm-import/Makefile`:
- Around line 1-5: Add a .PHONY declaration for the custom Makefile target to
match other packages: declare ".PHONY: generate" in the vm-import Makefile so
the "generate" target (the cozyvalues-gen and update-crd invocation) is treated
as phony; do not add duplicates for targets already provided by hack/package.mk
(e.g., all, clean, test).

In `@packages/apps/vm-import/README.md`:
- Around line 7-8: The README uses the incorrect capitalization "Github:";
update the text to the proper brand capitalization "GitHub:" by replacing the
string "Github:" with "GitHub:" in the README entry (the line containing the
link label currently showing "Github: <https://github.com/kubev2v/forklift>").

In `@packages/apps/vm-import/templates/networkmap.yaml`:
- Around line 14-22: The template currently treats any non-"pod" value as multus
and may render an empty destinationName; change the conditional to explicitly
check for "multus" (use else if eq .destinationType "multus") and only render
the multus block when that is true, and inside that block assert/require a
non-empty .destinationName (e.g., wrap name: {{ .destinationName | quote }} with
an if .destinationName check or use the template's required helper to fail
fast), leaving a safe default or no output for other destinationType values;
reference the symbols destinationType and destinationName in networkmap.yaml and
update the pod/multus conditional accordingly.

In `@packages/apps/vm-import/templates/plan.yaml`:
- Around line 17-25: The template currently gates the entire map block on
.Values.networkMap causing storage-only configs to omit map.storage; change the
outer condition to render map when either .Values.networkMap or
.Values.storageMap is set (use Helm's or: {{- if or .Values.networkMap
.Values.storageMap }}), then inside that block render network and storage
subsections independently using {{- if .Values.networkMap }} ... {{- end }} and
{{- if .Values.storageMap }} ... {{- end }}, keeping the existing fields (name:
{{ .Release.Name }}, namespace: {{ .Release.Namespace }}) for each subsection.
🧹 Nitpick comments (3)
packages/system/forklift/Chart.yaml (1)

1-3: LGTM!

Standard Chart.yaml structure following the cozy- prefix convention for system packages.

Consider adding a description field for documentation clarity:

 apiVersion: v2
 name: cozy-forklift
+description: Forklift controller for VM migration
 version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process
packages/system/forklift-operator/Makefile (1)

6-14: Consider extracting the inline Python filter into a small script for readability. It will make the update target easier to maintain and debug.

packages/apps/vm-import/values.schema.json (1)

16-27: Tighten schema: enum destinationType and require destinationName for multus.

This will prevent invalid values from reaching rendering/runtime.

Suggested schema update
         "properties": {
           "destinationName": {
             "description": "Name of the destination network (required if type is `multus`).",
             "type": "string"
           },
           "destinationNamespace": {
             "description": "Namespace of the destination network.",
             "type": "string"
           },
           "destinationType": {
             "description": "Destination type: `pod` for pod network, or `multus` for a Multus network.",
-            "type": "string"
+            "type": "string",
+            "enum": ["pod", "multus"]
           },
           "sourceId": {
             "description": "The managed object reference ID of the source network in vSphere.",
             "type": "string"
           }
-        }
+        },
+        "allOf": [
+          {
+            "if": {
+              "properties": { "destinationType": { "const": "multus" } }
+            },
+            "then": { "required": ["destinationName"] }
+          }
+        ]
       }

Comment thread packages/apps/vm-import/Makefile
Comment thread packages/apps/vm-import/README.md Outdated
Comment thread packages/apps/vm-import/templates/networkmap.yaml
Comment thread packages/apps/vm-import/templates/plan.yaml Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/apps/vm-import/templates/plan.yaml`:
- Line 9: The template sets "warm: {{ .Values.warm }}" which yields an
empty/invalid YAML when .Values.warm is undefined; change it to use Helm's
default function so the key always renders a valid boolean, e.g. replace the
expression with {{ default false .Values.warm }} (keep it unquoted) so the warm
field will be a proper boolean false when no value is provided.

Comment thread packages/apps/vm-import/templates/plan.yaml Outdated
@kvaps

Copy link
Copy Markdown
Member

Hey Matthieu ROBIN (@matthieu-robin),

With the vm-import application, it’s possible to specify which VMs to import via:

vms: []

What is the expected behavior here?

My understanding is that Forklift will import the VMs as lower-level KubeVirt objects. However, this wouldn’t create the higher-level virtualmachine objects in Cozystack, so they wouldn’t be visible in the dashboard or to the end user.

Am I mistaken?

Also, what is expected to happen when the vm-import application is removed? Should the imported VMs be deleted as well?

I think we need to clarify this workflow.

@dosubot dosubot Bot added size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/XL This PR changes 500-999 lines, ignoring generated files labels Feb 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In `@packages/apps/vm-import/ADOPTION_DESIGN.md`:
- Around line 54-84: Update the design doc to match the implemented behavior of
the vm-import-adoption-controller: replace the ConfigMap-based tracking section
with a clear description of the VMInstance CRD creation (including the CRD name
VMInstance and the fields populated) and add a short note explaining the
historical reason for the change from ConfigMap to VMInstance (or,
alternatively, modify the controller to create ConfigMaps instead if you prefer
the original design); ensure the doc references the controller name
vm-import-adoption-controller and the concrete resource (VMInstance) so readers
can find main.go and the CRD implementation.
- Around line 114-139: Update the HelmRelease manifest to use the stable API
version by replacing the apiVersion value "helm.toolkit.fluxcd.io/v2beta1" with
"helm.toolkit.fluxcd.io/v2" in the HelmRelease example; locate the manifest
block that defines kind: HelmRelease and change the apiVersion accordingly so
the example uses the GA Flux Helm toolkit API.

In `@packages/apps/vm-import/docs/examples/advanced-import.yaml`:
- Line 18: The thumbprint value under the thumbprint key is one octet short (19
colon-separated hex pairs) and must be a full 20-byte SHA-1 fingerprint; update
the thumbprint string in advanced-import.yaml (the thumbprint key) to contain 20
colon-separated two-digit hex pairs (matching the format used in the
simple-import example) so the example shows a valid SHA-1 fingerprint.

In `@packages/apps/vm-import/docs/scripts/adopt-vm.sh`:
- Around line 63-76: The post-increment expression ((DISK_COUNT++)) inside the
while loop causes a non-zero exit status when DISK_COUNT is 0 under set -e;
change the increment to a safe form that always returns 0 such as using
((DISK_COUNT+=1)) or DISK_COUNT=$((DISK_COUNT+1)) in the loop that reads
DISK_NAMES so the script doesn't exit early; update references to DISK_COUNT in
the same block (the while IFS= read -r disk; do ... done <<< "$DISK_NAMES"
section) and ensure DISKS_YAML building and the final echo remain unchanged.
- Around line 90-92: The step label for the network check is missing the
progress prefix; update the echo that prints "Checking network configuration..."
to follow the same step counter format (e.g., change it to "echo '[5/5] Checking
network configuration...'" or renumber all other step labels to use a 6-step
sequence) so it matches the existing pattern; locate the echo that precedes the
NETWORKS variable assignment (the block referencing VM_NAME and NAMESPACE) and
apply the consistent `[N/5]` or updated `[N/6]` prefix.

In `@packages/apps/vm-import/TEST_REPORT.md`:
- Line 106: The fenced code blocks in TEST_REPORT.md are missing language
specifiers (violates MD040); update each backtick-fenced block that currently
starts with ``` to include an explicit language hint (for example change ``` to
```text or ```bash as appropriate) so both occurrences are annotated and
markdownlint MD040 is satisfied.
- Around line 1-6: Remove the AI-authored test report file TEST_REPORT.md
(authored by "Claude Code") from the commit and do not commit AI-generated test
evidence as source-of-truth; either delete the file or move its contents to a
CI-generated artifact (e.g., capture helm template output as pipeline artifacts)
and replace the repository file with a human-verified test plan; also ensure any
references like "Ready to merge", "Kubernetes v1.34.3", "kubectl v1.35.0", or
mixed French/English notes are removed or rewritten by a human reviewer before
committing.

In `@packages/core/platform/sources/vm-adoption-controller.yaml`:
- Around line 1-14: The PackageSource uses the wrong API and flat structure;
change apiVersion to cozystack.io/v1alpha1 and restructure to the canonical
pattern: add spec.sourceRef referencing the cozystack-packages OCIRepository,
move the existing component under spec.variants[] -> components with the
vm-adoption-controller entry, and move dependsOn into the same spec.variants[]
as a list of package names (strings) instead of objects; ensure the kind stays
PackageSource and preserve namespace and path values when migrating them into
the spec.variants component definition.

In `@packages/system/vm-adoption-controller/images/controller/go.mod`:
- Around line 3-10: Update the module Go version and Kubernetes client
libraries: change the go directive from "go 1.23" to "go 1.25" and bump the k8s
libs in the require block—specifically update "k8s.io/client-go", "k8s.io/api",
and "k8s.io/apimachinery" to v0.35.0 (keep or reconcile k8s.io/klog/v2 and
kubevirt.io/api as needed for compatibility). After editing the go.mod entries,
run "go get k8s.io/client-go@v0.35.0" (and corresponding get commands for
api/apimachinery if needed) and "go mod tidy" to resolve transitive versions,
then rebuild/tests to ensure there are no breaking API changes.

In `@packages/system/vm-adoption-controller/images/controller/main.go`:
- Around line 226-251: The code currently defaults to adoption=true when the
Plan lookup via c.dynamicClient.Resource(gvr).Namespace(namespace).Get(...)
fails; change this to default to false: when Get returns an error, set enabled =
false, store that value in c.planCache[cacheKey]
(PlanCacheEntry.AdoptionEnabled) under c.cacheMutex, and log the failure at
warning level indicating adoption is being defaulted to disabled; ensure the
function returns false instead of true so adoption only proceeds when a Plan is
explicitly confirmed.
- Around line 268-272: The current call to unstructured.NestedMap (used with
vmUnstructured and assigned to spec) can return err==nil and found==false which
results in fmt.Errorf wrapping a nil error; change the check so you handle the
two cases separately: if err != nil return fmt.Errorf("failed to get VM spec:
%w", err) and if !found return a clear non-wrapped error (e.g., errors.New("VM
spec not found") or fmt.Errorf("VM spec not found")) so you never wrap a nil
error when calling unstructured.NestedMap.

In `@packages/system/vm-adoption-controller/README.md`:
- Around line 37-49: The YAML sample has duplicate top-level controller: blocks
so only the last one applies; merge the three blocks into a single controller
mapping containing watchInterval, watchNamespace, and namePrefix together
(preserve comments) so all keys take effect; update the README.md example to
show a single controller: block with watchInterval, watchNamespace, and
namePrefix fields.
🧹 Nitpick comments (12)
packages/system/vm-adoption-controller/templates/serviceaccount.yaml (1)

1-5: Hardcoded resource name prevents multiple releases and missing standard Helm labels.

The name vm-adoption-controller is hardcoded rather than derived from the release (e.g., {{ .Release.Name }}-vm-adoption-controller or a fullname helper). Since the corresponding ClusterRoleBinding (cluster-scoped) also uses this same hardcoded name, deploying two releases will cause a conflict.

Additionally, standard app.kubernetes.io labels are missing on all resources in this chart, which reduces observability and makes it harder to identify resources belonging to a specific release.

Suggested improvement
 apiVersion: v1
 kind: ServiceAccount
 metadata:
-  name: vm-adoption-controller
+  name: {{ include "vm-adoption-controller.fullname" . }}
   namespace: {{ .Release.Namespace }}
+  labels:
+    app.kubernetes.io/name: vm-adoption-controller
+    app.kubernetes.io/instance: {{ .Release.Name }}
+    app.kubernetes.io/managed-by: {{ .Release.Service }}

This applies equally to the ClusterRole, ClusterRoleBinding, Deployment, and ServiceAccount templates in this chart.

packages/system/vm-adoption-controller/images/controller/Dockerfile (3)

9-10: Only main.go is copied — consider copying all source files.

Currently only main.go is copied. If internal packages or additional .go files are added in the future, the build will silently exclude them. A more resilient pattern:

Suggested fix
 # Copy source
-COPY main.go .
+COPY . .

This is safe because go.mod and go.sum are already copied in the previous step (layer caching for dependency download is preserved).


1-1: Upgrade Go version to match repo standards. The Dockerfile correctly uses Go 1.23 from go.mod, but Go 1.23 is outdated—most other modules in the system use Go 1.25. Align this package with the latest version used across the codebase.


13-13: Hardcoded GOARCH=amd64 deviates from project multi-architecture build pattern.

The codebase consistently uses ARG TARGETOS and ARG TARGETARCH across other controllers (backup-controller, cozystack-controller, flux-plunger, kamaji, and others). Align this Dockerfile with the established pattern to enable multi-architecture builds.

Suggested fix
+ARG TARGETOS=linux
+ARG TARGETARCH=amd64
-RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o controller main.go
+RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -o controller main.go
packages/system/vm-adoption-controller/values.yaml (1)

3-5: Empty image.repository and image.tag defaults — will the deployment work out of the box?

Both repository and tag default to empty strings. If a user installs this chart without overriding these values, the deployment will reference an invalid image ("":"" or similar). Typically, a default image reference is provided so the chart is functional without mandatory overrides. If this is intentionally left for the packaging system to fill in, consider adding a comment noting that, or add a validation check in the deployment template.

packages/system/vm-adoption-controller/templates/deployment.yaml (1)

19-43: Consider adding a liveness probe.

The controller runs a polling loop with no health endpoint. If the reconciliation loop hangs (e.g., blocked on an API call without timeout), Kubernetes won't detect the failure and won't restart the pod. A minimal /healthz endpoint in the Go binary or a file-touch–based probe would improve resilience.

hack/e2e-apps/vm-import.bats (1)

86-92: Test cleanup only runs on success; resource leak on mid-test failure.

If an assertion fails before the cleanup block (lines 86-91), the VMImport, Secret, and HelmRelease will remain in the cluster, potentially causing subsequent test runs to fail or collide. Consider using a teardown function (Bats teardown) or a trap to ensure cleanup runs regardless of test outcome.

♻️ Example using Bats teardown or trap

You could add a file-level teardown or use setup/teardown per test:

# At the top of each `@test`, or use a file-level teardown
teardown() {
  kubectl -n tenant-test delete vmimport.apps.cozystack.io test --ignore-not-found 2>/dev/null || true
  kubectl -n tenant-test delete secret vmware-credentials-test --ignore-not-found 2>/dev/null || true
  kubectl -n tenant-test delete vmimport.apps.cozystack.io test-minimal --ignore-not-found 2>/dev/null || true
  kubectl -n tenant-test delete secret vmware-credentials-test-minimal --ignore-not-found 2>/dev/null || true
  kubectl -n tenant-test delete vmimport.apps.cozystack.io test-no-adoption --ignore-not-found 2>/dev/null || true
  kubectl -n tenant-test delete secret vmware-credentials-test-no-adoption --ignore-not-found 2>/dev/null || true
}
packages/apps/vm-import/ADOPTION_DESIGN.md (1)

1-10: Design document is written in French while the rest of the codebase is in English.

This limits accessibility for non-French-speaking contributors and reviewers. Consider translating to English for consistency, or at minimum adding an English summary at the top.

packages/system/vm-adoption-controller/images/controller/main.go (4)

43-46: Dead code: clientset field and vmDisk* constants are never used.

  • vmDiskGroup, vmDiskVersion, vmDiskKind (lines 43-46) are declared but never referenced.
  • clientset (line 97) is created in main() and stored in the struct but never accessed by any method — only dynamicClient is used.

This adds unnecessary dependencies and confusion.

♻️ Proposed cleanup
-	// VMDisk GVR
-	vmDiskGroup   = "apps.cozystack.io"
-	vmDiskVersion = "v1alpha1"
-	vmDiskKind    = "VMDisk"
-
 type AdoptionController struct {
-	clientset     *kubernetes.Clientset
 	dynamicClient dynamic.Interface
 	planCache     map[string]*PlanCacheEntry
 	cacheMutex    sync.RWMutex
 }

And remove the kubernetes.NewForConfig(config) call and the "k8s.io/client-go/kubernetes" import in main().

Also applies to: 74-78, 96-101


52-94: No signal handling — graceful shutdown is unreachable.

context.Background() is never cancelled, so ctx.Done() in Run() (line 119) will never fire. When Kubernetes sends SIGTERM, the controller ignores it and is eventually SIGKILL'd.

For a stateless polling controller this is functionally safe, but it prevents clean log messages and could leave mid-flight API calls interrupted.

♻️ Proposed signal handling
+	"os"
+	"os/signal"
+	"syscall"
 ...
 	// Run controller
-	ctx := context.Background()
+	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
+	defer cancel()
 	controller.Run(ctx)

Also applies to: 108-126


328-331: Use strings.TrimPrefix for idiomatic prefix stripping.

Manual length check + slice is fragile and harder to read.

♻️ Proposed fix

Add "strings" to imports, then:

-		// Extract disk name (remove vm-disk- prefix if present)
-		if len(diskName) > 8 && diskName[:8] == "vm-disk-" {
-			diskName = diskName[8:]
-		}
+		// Extract disk name (remove vm-disk- prefix if present)
+		diskName = strings.TrimPrefix(diskName, "vm-disk-")

147-204: Each VM is fetched twice — once in getForkliftVMs (unstructured list) and again in adoptVM (get by name).

The full unstructured data is already available in item (line 172) but is discarded. Passing the unstructured.Unstructured directly to adoptVM would eliminate one API call per VM.

Comment thread packages/apps/vm-import/ADOPTION_DESIGN.md Outdated
Comment on lines +114 to +139
# Crée la HelmRelease avec adoption
cat <<EOF | kubectl apply -f -
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: $VM_NAME
namespace: $NAMESPACE
spec:
chart:
spec:
chart: vm-instance
sourceRef:
kind: HelmRepository
name: cozystack
install:
createNamespace: false
# N'essaie pas de créer la VM (elle existe déjà)
disableWait: true
upgrade:
# Ne modifie pas la VM existante
force: false
values:
# Valeurs extraites de la VM existante
...
EOF
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Flux HelmRelease v2beta1 deprecated v2 stable version

💡 Result:

HelmRelease v2beta1 (helm.toolkit.fluxcd.io/v2beta1) is deprecated and (in Flux) effectively removed starting with Flux v2.2.0—clusters upgraded to Flux v2.2+ must stop using v2beta1 manifests. [4], [5]

What to use instead (stable)

Use the stable (GA) API:

  • apiVersion: helm.toolkit.fluxcd.io/v2 (kind: HelmRelease) [2], [3]

(v2beta2 existed as an intermediate beta, but it’s also deprecated and slated for removal in a future release.) [1]

Minimal migration

  1. Update your manifests:
    • helm.toolkit.fluxcd.io/v2beta1helm.toolkit.fluxcd.io/v2 [2], [3]
  2. Make sure your Flux/helm-controller installation is upgraded and CRDs are updated (so the cluster has the v2 HelmRelease CRD installed). [1], [3]

Notable schema removals in the v2 API (watch for these if you used them)

Some deprecated fields were removed from the HelmRelease v2 API (e.g. valuesFilevaluesFiles, and older kustomize post-render patch fields → patches). [1]

Sources: Flux v2.3 announcement (deprecations/removals) [1], Helm API reference v2 [2], ControlPlane/Flux API version matrices showing HelmRelease GA as v2 [3], Flux v2.2.0 notes about v2beta1 deprecation/removal [4], Flux controller API versioning/deprecation policy [5].


Replace v2beta1 with the stable v2 API version in the HelmRelease manifest.

Line 116 uses the deprecated helm.toolkit.fluxcd.io/v2beta1 API version. Update it to helm.toolkit.fluxcd.io/v2 (stable GA since Flux 2.1.0, with v2beta1 removal enforced in Flux v2.2.0+). Anyone following this documentation example should use the current stable API.

🤖 Prompt for AI Agents
In `@packages/apps/vm-import/ADOPTION_DESIGN.md` around lines 114 - 139, Update
the HelmRelease manifest to use the stable API version by replacing the
apiVersion value "helm.toolkit.fluxcd.io/v2beta1" with
"helm.toolkit.fluxcd.io/v2" in the HelmRelease example; locate the manifest
block that defines kind: HelmRelease and change the apiVersion accordingly so
the example uses the GA Flux Helm toolkit API.

Comment thread packages/apps/vm-import/docs/examples/advanced-import.yaml Outdated
Comment thread packages/apps/vm-import/docs/scripts/adopt-vm.sh Outdated
Comment on lines +90 to +92
# Check for networks
echo "Checking network configuration..."
NETWORKS=$(kubectl get vm "$VM_NAME" -n "$NAMESPACE" -o json 2>/dev/null | \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent step label — missing [X/5] prefix.

Line 91 uses a bare "Checking network configuration..." while all other steps use the [N/5] counter format. This is the implicit step between 4 and 5.

Proposed fix
-echo "Checking network configuration..."
+echo -e "${YELLOW}[4.5/5]${NC} Checking network configuration..."

Alternatively, renumber all steps to account for 6 total steps.

🤖 Prompt for AI Agents
In `@packages/apps/vm-import/docs/scripts/adopt-vm.sh` around lines 90 - 92, The
step label for the network check is missing the progress prefix; update the echo
that prints "Checking network configuration..." to follow the same step counter
format (e.g., change it to "echo '[5/5] Checking network configuration...'" or
renumber all other step labels to use a 6-step sequence) so it matches the
existing pattern; locate the echo that precedes the NETWORKS variable assignment
(the block referencing VM_NAME and NAMESPACE) and apply the consistent `[N/5]`
or updated `[N/6]` prefix.

Comment on lines +3 to +10
go 1.23

require (
k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0
k8s.io/client-go v0.31.0
k8s.io/klog/v2 v2.130.1
kubevirt.io/api v1.3.1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

latest Go version February 2026

💡 Result:

As of February 6, 2026, the latest stable Go release is Go 1.25.7 (released February 4, 2026). [1]

Latest major series: Go 1.25 (Go 1.25.0 released August 12, 2025). [1]

Source(s)
[1] Go Release History (go.dev) (go.dev)

Citations:


🌐 Web query:

Kubernetes client-go latest release version 2026

💡 Result:

As of Feb 6, 2026, the latest stable k8s.io/client-go release tag is v0.35.0 (published Dec 17, 2025, aligned with Kubernetes v1.35.0). [1][2]


🌐 Web query:

Go 1.23 end of life support timeline

💡 Result:

Go 1.23 support follows Go’s release policy: a major release is supported until there are two newer major releases (i.e., only the latest two major versions are supported) [1]. The security policy likewise prepares fixes for the two most recent major releases [2].

Go 1.23 end-of-life (end of upstream support) timeline

  • Go 1.23.0 released: August 13, 2024 [3]
  • Last 1.23 patch release: Go 1.23.12 (August 6, 2025) [4]
  • End of upstream support (EOL): August 12, 2025 — when Go 1.25.0 shipped as the second newer major release after 1.23 (with 1.24 in between) [1][5]. After this date, upstream no longer backports critical/security fixes to 1.23 [1][2].

(As of February 6, 2026, the supported major releases are Go 1.24 and 1.25 [1].)

Sources

  1. Go Release Policy (go.dev release history page) [1]
  2. Go Security Policy (two most recent major releases) [2]
  3. Go 1.23 release announcement [3]
  4. Go 1.23.12 release entry [4]
  5. Go 1.25.0 release entry/date [5]

Upgrade Go and Kubernetes client libraries — current versions are unsupported.

Go 1.23 reached end-of-life on August 12, 2025, and no longer receives security patches or critical fixes. The Kubernetes client libraries at v0.31.0 (August 2024) are four minor versions behind the current stable v0.35.0 and similarly lack security updates.

Bump to Go 1.25.x (latest stable: 1.25.7 as of February 2026) and Kubernetes client-go v0.35.0 to restore security support and access current bug fixes.

🤖 Prompt for AI Agents
In `@packages/system/vm-adoption-controller/images/controller/go.mod` around lines
3 - 10, Update the module Go version and Kubernetes client libraries: change the
go directive from "go 1.23" to "go 1.25" and bump the k8s libs in the require
block—specifically update "k8s.io/client-go", "k8s.io/api", and
"k8s.io/apimachinery" to v0.35.0 (keep or reconcile k8s.io/klog/v2 and
kubevirt.io/api as needed for compatibility). After editing the go.mod entries,
run "go get k8s.io/client-go@v0.35.0" (and corresponding get commands for
api/apimachinery if needed) and "go mod tidy" to resolve transitive versions,
then rebuild/tests to ensure there are no breaking API changes.

Comment thread packages/system/vm-adoption-controller/images/controller/main.go
Comment thread packages/system/vm-adoption-controller/images/controller/main.go
Comment thread packages/system/vm-adoption-controller/images/controller/main.go Outdated
Comment thread packages/system/vm-adoption-controller/README.md
@matthieu-robin

Copy link
Copy Markdown
Collaborator Author

Hey Andrei Kvapil (@kvaps),

You're right — the original implementation had several gaps preventing imported VMs from being visible in the dashboard. Here's a clarification of the intended workflow, along with the fixes I've pushed.

Workflow: how imported VMs become visible in the dashboard

  1. Forklift imports VMs from VMware → creates native KubeVirt VirtualMachine + DataVolume resources
  2. The vm-adoption-controller (deployed as a platform package in the IaaS bundle) detects VMs with the forklift.konveyor.io/plan label
  3. It checks the Plan annotation vm-import.cozystack.io/adoption-enabled: "true"
  4. If enabled, it:
    • Annotates the existing VM with Helm metadata (meta.helm.sh/release-name, app.kubernetes.io/managed-by: Helm) so Helm can adopt it
    • Creates a VMInstance CRD via the aggregated API, with fullnameOverride set to the original VM name
    • Extracts disks (via dvName to reference Forklift DataVolumes directly) and Multus subnets from the existing VM
  5. The standard cozystack-controller picks up the VMInstance → creates a HelmRelease
  6. Helm adopts the existing VM (thanks to the pre-applied annotations) instead of creating a duplicate
  7. The VM appears in the dashboard as a regular VM Instance

This happens automatically within ~30 seconds of import completion.

What happens when vm-import is deleted

Deleted (temporary migration objects):

  • Migration, Plan, NetworkMap, StorageMap

Preserved:

  • Providers (source/destination) — kept via helm.sh/resource-policy: keep, reusable for future imports
  • All imported VMs — not part of the Helm release, never deleted
  • All DataVolumes and disks — never deleted
  • VMInstance CRDs and their HelmReleases — VMs remain visible and manageable in the dashboard

Fixes in this update

Architectural (the adoption flow was fundamentally broken)

Fix Detail
VM duplication Helm was creating vm-instance-<name> instead of adopting the existing <name>. Fixed by passing fullnameOverride and pre-annotating the VM with Helm ownership metadata
Disk lookup failure vm-instance template expected vm-disk-<name> but Forklift DVs have arbitrary names. Added dvName field to the disk spec for direct DataVolume references
Networks lost Controller was passing subnets: [], losing all Multus networks. Now extracts Multus interfaces from the existing VM
running vs runStrategy Controller used deprecated running boolean. Now converts to runStrategy enum (Always/Halted)
cloud-init re-execution cloudInitSeed: "imported-vm" changed the firmware UUID, risking cloud-init re-run. Changed to cloudInitSeed: "" which preserves the existing UUID

Infrastructure

Fix Detail
PackageSource wrong API vm-adoption-controller.yaml used source.toolkit.fluxcd.io/v1 instead of cozystack.io/v1alpha1. Rewrote following the forklift.yaml pattern
Missing from IaaS bundle vm-adoption-controller was never deployed. Added to iaas.yaml before vm-import-application
Default adoption=true on Plan lookup failure Changed to false — a failed lookup should not trigger adoption

Functional

Fix Detail
((DISK_COUNT++)) crash Fails with set -e when DISK_COUNT=0. Changed to DISK_COUNT=$((DISK_COUNT + 1)) in adopt-vm.sh
fmt.Errorf wrapping nil Separated err != nil and !found cases for clear error messages

Cleanup

  • Removed AI-generated TEST_REPORT.md
  • Rewrote ADOPTION_DESIGN.md to reflect actual implementation (VMInstance CRD, not ConfigMap)
  • Fixed duplicate YAML keys in vm-adoption-controller/README.md

Files changed

  • packages/core/platform/sources/vm-adoption-controller.yaml — rewritten
  • packages/core/platform/templates/bundles/iaas.yaml — added controller
  • packages/system/vm-adoption-controller/images/controller/main.go — Helm adoption, dvName, subnets, runStrategy
  • packages/apps/vm-instance/templates/vm.yamldvName support in disk lookup + volume reference
  • packages/apps/vm-instance/values.schema.json — added optional dvName field
  • packages/apps/vm-import/docs/scripts/adopt-vm.sh — DISK_COUNT fix
  • packages/apps/vm-import/ADOPTION_DESIGN.md — rewritten
  • packages/apps/vm-import/TEST_REPORT.md — deleted
  • packages/system/vm-adoption-controller/README.md — fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
packages/apps/vm-import/docs/examples/simple-import.yaml (1)

13-17: Use explicit placeholder tokens for secret values in the example.

Line 14 and Line 17 are valid samples, but <REPLACE_...> style placeholders are safer for copy/paste usage in docs.

Small docs hardening tweak
-  user: administrator@vsphere.local
-  password: MySecurePassword123!
+  user: "<VCENTER_USERNAME>"
+  password: "<VCENTER_PASSWORD>"
@@
-  thumbprint: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD"
+  thumbprint: "<VCENTER_TLS_SHA1_FINGERPRINT>"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/apps/vm-import/docs/examples/simple-import.yaml` around lines 13 -
17, Replace the hard-coded example secrets in the YAML (the values for keys
user, password, and thumbprint) with explicit placeholder tokens so readers
don't accidentally copy real credentials; update the "user" value to something
like <REPLACE_USERNAME>, "password" to <REPLACE_PASSWORD>, and "thumbprint" to
<REPLACE_THUMBPRINT> (keep the comment showing how to obtain a real thumbprint
intact), ensuring examples in the file reference these placeholder tokens
instead of real-looking secrets.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/apps/vm-import/ADOPTION_DESIGN.md`:
- Around line 93-105: The fenced code block that begins with "Forklift imports
VM" is untyped; update that block in ADOPTION_DESIGN.md by adding a language tag
(e.g., change ``` to ```text) so the code fence is typed (```text ... ```),
ensuring markdownlint MD040 is satisfied for the architecture diagram block.

In `@packages/apps/vm-import/docs/adoption.md`:
- Around line 17-40: The docs contradict each other about enableAdoption
behavior: update the "Method 1: Automatic Labeling (Default)" section to match
the controller implementation by explicitly stating whether enableAdoption:true
only applies labels or also creates VMInstance resources for full management;
reference the actual controller flow symbols (enableAdoption, VMInstance,
ADOPTION_DESIGN and README adoption descriptions) and either (a) change the text
to say enableAdoption:true performs only labeling with the listed read-only
limitations, or (b) document the distinct modes/flags (label-only vs full
adoption) and the exact flag/field that triggers VMInstance creation so
operators know when Cozystack will take full lifecycle control.

In `@packages/apps/vm-import/README.md`:
- Around line 186-189: Add a brief note to README.md stating that setting vms:
[] (the default) produces no migration plan and is a no-op for execution; update
the table description for the `vms` entry and add a short sentence in the “How
to use” section clarifying that the Plan template only renders when `vms` is
non-empty so an empty `vms` list results in no migration actions.

In `@packages/apps/vm-instance/templates/vm.yaml`:
- Around line 67-68: The template uses the computed variable $dvName and the
lookup call (lookup "cdi.kubevirt.io/v1beta1" "DataVolume" $.Release.Namespace
$dvName) which depends on .Values.disks[].dvName being present in the generated
schema; add a dvName field to the disks entries in values.yaml (e.g., include
dvName: "" or null for each disk item) so the chart schema generator will pick
up and expose .Values.disks[].dvName in values.schema.json, ensuring the lookup
override works in the UI/validation.

In `@packages/apps/vm-instance/values.schema.json`:
- Around line 34-37: The dvName property was manually added to
values.schema.json (which is auto-generated) and will be overwritten; instead
add a dvName entry under the disks items in values.yaml with the same
description/comment so cozyvalues-gen will include it during generation, then
run make generate to regenerate values.schema.json; reference the dvName
property, the disks items structure in values.yaml, the cozyvalues-gen
generator, and the make generate step when making the change.

---

Nitpick comments:
In `@packages/apps/vm-import/docs/examples/simple-import.yaml`:
- Around line 13-17: Replace the hard-coded example secrets in the YAML (the
values for keys user, password, and thumbprint) with explicit placeholder tokens
so readers don't accidentally copy real credentials; update the "user" value to
something like <REPLACE_USERNAME>, "password" to <REPLACE_PASSWORD>, and
"thumbprint" to <REPLACE_THUMBPRINT> (keep the comment showing how to obtain a
real thumbprint intact), ensuring examples in the file reference these
placeholder tokens instead of real-looking secrets.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4104caed-4116-4600-aabf-3f7cd86ba5a9

📥 Commits

Reviewing files that changed from the base of the PR and between ff737ca and 8f3f95b.

⛔ Files ignored due to path filters (1)
  • packages/apps/vm-import/logos/vm-import.svg is excluded by !**/*.svg
📒 Files selected for processing (23)
  • hack/e2e-apps/vm-import.bats
  • packages/apps/vm-import/.helmignore
  • packages/apps/vm-import/ADOPTION_DESIGN.md
  • packages/apps/vm-import/Chart.yaml
  • packages/apps/vm-import/Makefile
  • packages/apps/vm-import/README.md
  • packages/apps/vm-import/charts/cozy-lib
  • packages/apps/vm-import/docs/adoption.md
  • packages/apps/vm-import/docs/examples/advanced-import.yaml
  • packages/apps/vm-import/docs/examples/monitoring-import.md
  • packages/apps/vm-import/docs/examples/simple-import.yaml
  • packages/apps/vm-import/docs/scripts/adopt-vm.sh
  • packages/apps/vm-import/templates/dashboard-resourcemap.yaml
  • packages/apps/vm-import/templates/migration.yaml
  • packages/apps/vm-import/templates/networkmap.yaml
  • packages/apps/vm-import/templates/plan.yaml
  • packages/apps/vm-import/templates/provider.yaml
  • packages/apps/vm-import/templates/storagemap.yaml
  • packages/apps/vm-import/templates/workloadmonitor.yaml
  • packages/apps/vm-import/values.schema.json
  • packages/apps/vm-import/values.yaml
  • packages/apps/vm-instance/templates/vm.yaml
  • packages/apps/vm-instance/values.schema.json
✅ Files skipped from review due to trivial changes (2)
  • packages/apps/vm-import/docs/examples/advanced-import.yaml
  • packages/apps/vm-import/docs/examples/monitoring-import.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/apps/vm-import/Chart.yaml
  • packages/apps/vm-import/docs/scripts/adopt-vm.sh
  • packages/apps/vm-import/templates/workloadmonitor.yaml
  • hack/e2e-apps/vm-import.bats
  • packages/apps/vm-import/.helmignore
  • packages/apps/vm-import/values.yaml

Comment thread packages/apps/vm-import/ADOPTION_DESIGN.md Outdated
Comment thread packages/apps/vm-import/docs/adoption.md Outdated
Comment thread packages/apps/vm-import/README.md Outdated
Comment on lines +67 to +68
{{- $dvName := default (printf "vm-disk-%s" $disk.name) $disk.dvName }}
{{- $dv := lookup "cdi.kubevirt.io/v1beta1" "DataVolume" $.Release.Namespace $dvName }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Template logic is sound, but depends on schema change that won't persist.

The dvName variable and lookup logic are correctly implemented—defaulting to vm-disk-<name> while allowing explicit overrides for imported VMs. The error message correctly references the computed name.

However, this template relies on .Values.disks[].dvName being available, which depends on the schema change in values.schema.json. Since that file is auto-generated and the manual edit gets removed (per pipeline warning), .dvName may not be exposed to users through the UI/validation.

Ensure the corresponding dvName field is added to values.yaml so the schema regeneration includes it.

Also applies to: 80-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/apps/vm-instance/templates/vm.yaml` around lines 67 - 68, The
template uses the computed variable $dvName and the lookup call (lookup
"cdi.kubevirt.io/v1beta1" "DataVolume" $.Release.Namespace $dvName) which
depends on .Values.disks[].dvName being present in the generated schema; add a
dvName field to the disks entries in values.yaml (e.g., include dvName: "" or
null for each disk item) so the chart schema generator will pick up and expose
.Values.disks[].dvName in values.schema.json, ensuring the lookup override works
in the UI/validation.

Comment on lines +34 to +37
"dvName": {
"description": "Explicit DataVolume name to reference. If set, used instead of the default vm-disk-<name> convention.",
"type": "string"
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Manual edit to auto-generated file will be lost.

The pipeline warning confirms that dvName was removed during automated generation. This file is produced by cozyvalues-gen from values.yaml and regenerated on make generate.

To persist this field, add the dvName property to values.yaml (under the disks items structure) with appropriate description comments. The generator will then include it in the schema automatically.

Based on learnings: "In reviews for cozystack, treat any values.schema.json files as auto-generated: they are produced from values.yaml by the cozyvalues-gen tool and regenerated on make generate. Do not suggest manual edits to these files."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/apps/vm-instance/values.schema.json` around lines 34 - 37, The
dvName property was manually added to values.schema.json (which is
auto-generated) and will be overwritten; instead add a dvName entry under the
disks items in values.yaml with the same description/comment so cozyvalues-gen
will include it during generation, then run make generate to regenerate
values.schema.json; reference the dvName property, the disks items structure in
values.yaml, the cozyvalues-gen generator, and the make generate step when
making the change.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/apps/vm-import/README.md (1)

1-213: Do not manually edit this README—it is auto-generated by cozyvalues-gen from values.yaml and will be overwritten on make generate.

This README is produced by the cozyvalues-gen tool (see packages/apps/vm-import/Makefile). Any manual edits to documentation, parameter descriptions, or the parameters table will be lost during regeneration. To update documentation, modify the parameter descriptions in values.yaml instead, and they will be included in the regenerated README on the next build.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/apps/vm-import/README.md` around lines 1 - 213, The README is
missing the auto-generated notice; add a clear top-of-file warning that this
file is auto-generated by cozyvalues-gen from values.yaml and will be
overwritten by `make generate` (matching the wording in the review), so any
edits should be made in values.yaml or the generator; reference the generator
and the Makefile target (cozyvalues-gen, values.yaml, Makefile) in the notice
and place it immediately under the main title so it appears first in
packages/apps/vm-import/README.md.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/apps/vm-import/README.md`:
- Around line 147-148: The README examples use the deprecated spec.running
field; update the kubectl patch vm examples that reference spec.running to use
spec.runStrategy instead (set runStrategy:"Always" to start continuously and
runStrategy:"Halted" to stop), i.e., locate the kubectl patch vm lines that
mention spec.running and replace them to patch spec.runStrategy with the
appropriate string values so examples align with KubeVirt v1.3+ and cozystack
runStrategy migration.

---

Nitpick comments:
In `@packages/apps/vm-import/README.md`:
- Around line 1-213: The README is missing the auto-generated notice; add a
clear top-of-file warning that this file is auto-generated by cozyvalues-gen
from values.yaml and will be overwritten by `make generate` (matching the
wording in the review), so any edits should be made in values.yaml or the
generator; reference the generator and the Makefile target (cozyvalues-gen,
values.yaml, Makefile) in the notice and place it immediately under the main
title so it appears first in packages/apps/vm-import/README.md.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2cdb45cf-be46-4021-be9e-3985cc4b5b28

📥 Commits

Reviewing files that changed from the base of the PR and between 8f3f95b and 95a8b76.

📒 Files selected for processing (4)
  • packages/apps/vm-import/ADOPTION_DESIGN.md
  • packages/apps/vm-import/README.md
  • packages/apps/vm-import/docs/adoption.md
  • packages/apps/vm-instance/values.yaml
✅ Files skipped from review due to trivial changes (1)
  • packages/apps/vm-import/ADOPTION_DESIGN.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/apps/vm-import/docs/adoption.md

Comment thread packages/apps/vm-import/README.md Outdated
@github-actions github-actions Bot added the area/virtualization Issues or PRs related to virtualization (kubevirt, cdi, vmi, vm-import) label May 12, 2026
@matthieu-robin

Copy link
Copy Markdown
Collaborator Author

Andrei Kvapil (@kvaps) Hello,

I did some modifications, could you please tell me what do you think about?

Thanks a lot

@matthieu-robin
Matthieu ROBIN (matthieu-robin) force-pushed the vmware-import branch 2 times, most recently from ac1e442 to c075a6f Compare June 22, 2026 20:42
Matthieu and others added 8 commits August 19, 2026 10:16
The Secret example named administrator@vsphere.local and nothing said what
privileges a migration actually requires, so the path of least resistance
was to hand Forklift a vCenter Administrator. That credential is read by
the cluster-privileged Forklift controller, so the account's reach is the
credential's blast radius.

Name a dedicated account in the examples and add a prerequisites section
explaining why a read-only account is not sufficient either: reading disks
through the VDDK, powering the source VM off at cutover, and the snapshots
a warm migration takes are all writes, while only inventory collection is
genuinely read-only. A read-only account therefore lists the VMs and then
fails at transfer time.

The section points at the MTV Prerequisites chapter for the authoritative
privilege list rather than restating it here, since that list tracks the
Forklift release and would rot in this repository. It also covers the
second credential set migrationHosts introduces against the ESXi hosts.

Added as 3.7 rather than inserted earlier so the existing cross-references
into section 3 keep pointing at the right subsections.

Signed-off-by: Matthieu <matthieu@hidora.com>
fullnameOverride reached the tenant-facing schema so the adoption
controller can name the rendered VirtualMachine after the VM it replaces.
It feeds virtual-machine.fullname, which is metadata.name on the
VirtualMachine, and nothing stopped a tenant from editing it afterwards:
Helm would then prune the VirtualMachine and recreate it under the new
name, re-rolling a running VM. Disks survive, since they are DataVolumes
owned by their own vm-disk releases, so this is disruption rather than
data loss.

Keeping the field out of the schema is not an option — the API rejects
what the schema does not declare, and the controller has to set it — so
guard it with a CEL transition rule instead, the same mechanism vm-disk
uses for storageClass. A transition rule is not evaluated when the old
object lacks the field, so the controller can still stamp it at creation
and only later edits are refused.

Signed-off-by: Matthieu <matthieu@hidora.com>
…ates

The dashboard Role covered providers, plans, migrations and the two maps,
but not hosts. A tenant using migrationHosts to route disk transfer over
a per-ESXi-host address therefore created Host objects its own dashboard
could not display.

Adding the resource alone would not have been enough: the rule is scoped
by resourceNames, which takes no patterns, and host.yaml names each Host
<release>-<id> rather than one of the three names already listed. Range
over the declared migrationHosts to enumerate them.

Signed-off-by: Matthieu <matthieu@hidora.com>
Plans are searched cluster-wide because a Plan may live in a different
namespace than the VM it migrated, so resolvePlan listed every Plan in
the cluster and scanned it linearly. It runs once per candidate VM in
getForkliftVMs and again in adoptVM, which made a reconcile pass cost
O(pending VMs x all Plans) — repeated every 15 seconds.

Build the UID to Plan index from a single List the first time a pass
needs it, and drop it at the top of reconcile so a Plan edited between
ticks is still picked up. Lookups after the first are then free, adoptVM
included.

The lookup stays lazy rather than eager so resolvePlan keeps working
when called outside a reconcile pass, which is how the tests exercise it.

Signed-off-by: Matthieu <matthieu@hidora.com>
PROJECT_SUMMARY.md was 245 lines of development narrative — what was
built, what was fixed along the way, what is left to productize — shipped
inside an application package that should carry operator documentation.
Its "fixes made" section already duplicated section 6 of the migration
guide, and its follow-up list duplicated section 7.

One part was not narrative and not duplicated: the measured comparison of
the two transfer paths, which is what tells an operator whether
skipGuestConversion is worth arranging. Move that table into the guide,
next to the architecture it compares, and delete the rest.

Signed-off-by: Matthieu <matthieu@hidora.com>
The package still called $(call settag,$(TAG)), a macro removed from main in
ed0297e; it expands to nothing, so buildx was handed a bare "repo:" and
failed with "invalid reference format". Every Build job on the PR has been red
on this since, and the recent rebase did not clear it because the stale call
lives in this Makefile rather than in the shared includes.

Switch to image-tags/cache-args like the sibling controllers.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
Installing Forklift unconditionally means every existing iaas cluster picks up
a large operator manifest, its own CRDs and broad RBAC on upgrade, plus an
adoption controller holding cluster-wide create on VMInstances -- for a feature
only a cluster migrating off vSphere needs.

Gate the three packages behind bundles.enabledPackages instead, alongside
gpu-operator and vm-default-images.

The accompanying test pins the rendered package count: absence of a single
document cannot be asserted directly here, because a documentSelector matching
nothing fails rather than counting zero, and containsDocument with not passes
whenever any one document differs.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
…place

Forklift owns every migrated PVC by the VirtualMachine it creates -- EnsureVM
patches each one with a blockOwnerDeletion ownerReference, upstream comment
"so that they'll be cleaned up when the VirtualMachine is removed". Adoption
then deleted that VM to free the name the VMInstance takes, and the garbage
collector took the disk with it: the clone lost its source mid-flight, the
DataVolume sat in CloneWithoutSource and the VM in ErrorPvcNotFound, with a
completed transfer destroyed by the adoption meant to consume it.

The code assumed the opposite -- "KubeVirt does not delete PVCs with a VM" --
which holds for an ordinary VM but not for a Forklift import.

Two changes, both scoped to same-namespace adoption:

Detach before deleting. Only the volumes this adoption consumes, and only
ownerReferences pointing at this VM, so an import that never reaches adoption
keeps Forklift's cleanup of abandoned migrations.

Stop cloning. On the raw-copy path Forklift already wrote the disk into the
target namespace with the right size, StorageClass and content, so the clone
copied a whole disk to stand still and left two full copies behind for good.
The disks keep referencing the imported PVC; vm-instance already falls back to
a bare PersistentVolumeClaim when no DataVolume of that name exists. The
cross-namespace virt-v2v path is unchanged -- there the copy is the point.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

NOT LGTM

The cross-tenant isolation design is sound and well tested, but the adoption controller's Go logic has several confirmed data-destruction paths (a transient API error and a name collision both silently delete the imported VM), the disk clone chain leaks two permanent copies per disk, warm migrations can never complete, the shipped e2e suite runs nothing, and the manual adopt-vm.sh fallback produces a values file that cannot adopt. These are blocking.

Findings

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:520, getTargetNamespace defaults to the Plan namespace on any Get error, not just NotFound

The Get error branch is unconditional (if err != nil { return namespace }), it is not apierrors.IsNotFound-gated. The Plan was already resolved in hand at the call site (resolvePlan), so this second namespaced Get is redundant and only adds a failure mode. Scenario: an admin Plan in cozy-forklift carries vm-import.cozystack.io/target-namespace: tenant-a. On a transient Get error getTargetNamespace returns cozy-forklift. On the virt-v2v path vm.Namespace == planNamespace == cozy-forklift, so targetNamespace == vm.Namespace, wrapDisksAsVMDisks early-returns (src==dst, no clone), the VMInstance is created in the privileged namespace, and releaseSourceVM takes the same-namespace branch (main.go:1146-1160) that detaches the PVC ownerRefs and deletes the Forklift VM. It is non-retryable because the source VM the controller keys on is gone. getPlanPreset (main.go:553-563) has the same swallow-and-default-on-error shape and silently drops the operator's chosen instanceType/instanceProfile on a transient error. Fix: propagate the error and requeue on anything that is not a real NotFound, or drop the redundant Get and reuse the already-resolved Plan.

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:996, idempotency check matches a VMInstance by name only, with no identity verification

The "already created" short-circuit does a Get(vmInstanceName) in the target namespace and, on success, jumps straight to releaseSourceVM. It never reads back the vm-import.cozystack.io/original-vm-name / -namespace annotations it writes on create (main.go:1032-1033), and vmInstanceName == vm.Name. Scenario: a tenant already runs an unrelated VMInstance web, and a VMware VM named web is imported into the same namespace. The Get finds the pre-existing web, the controller treats it as its own earlier success, and releaseSourceVM detaches the import's PVC ownerRefs and deletes the imported Forklift VM. The import is silently destroyed and its disks orphaned, while the unrelated VMInstance is untouched. Fix: verify the found VMInstance carries this import's identity annotations before releasing the source VM.

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:651, cross-namespace adoption leaks two permanent full-disk copies per disk

The virt-v2v cross-namespace path clones the imported PVC into <vmDiskName>-src (main.go:651-655), then ensureVMDisk clones that into vm-disk-<name> (main.go:627, 682). Nothing ever deletes the -src DataVolume/PVC, and the original PVC in cozy-forklift also stays (the source VM is only labeled). The only Delete in the controller is the kubevirt VM at main.go:1155, and RBAC could not delete volumes anyway: clusterrole.yaml:22-24 grants datavolumes no delete and clusterrole.yaml:35-37 grants PVCs no delete. Net result on the cross-namespace path is three full copies of every migrated disk on replicated storage, one referenced and two permanent garbage with no ownerRef for GC. The same-namespace/raw-copy path correctly avoids this via the early return. Fix: delete the intermediate -src volume after the VMDisk clone completes (and grant the verb), or clone directly into the final target.

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:1140, cross-namespace adoption leaves the source VM running and stamps the wrong release namespace on it

On the cross-namespace branch labelVMAsAdopted only labels the Forklift VM, it never halts or deletes it (main.go:1140-1145). The source VM keeps its inherited runStrategy (extracted at main.go:767-781, default Always) and keeps running off the original disk next to the tenant clone, so both boot with the same guest identity and MAC. Separately, labelVMAsAdopted writes meta.helm.sh/release-namespace: <source VM namespace> (main.go:1191), the privileged namespace, for a release that actually lives in the tenant namespace. That is false ownership metadata: a future vm-instance-<name> release in cozy-forklift could adopt and rewrite this VM. Fix: stop/delete the source VM on the cross-namespace path once the clone is confirmed, and either omit the Helm ownership annotations on the source VM or set them to the real release namespace.

[MAJOR] packages/apps/vm-import/templates/migration.yaml:1, warm: true can never complete and a helm-upgrade that adds a VM never migrates it

The Migration is rendered once with only spec.plan.{name,namespace}, there is no spec.cutover anywhere (grep for cutover in packages/apps/vm-import/ hits docs only). warm is a real exposed value (plan.yaml:29, values.yaml:35, values.schema.json:56, advanced-import.yaml:44). A Forklift warm migration precopies indefinitely until a cutover timestamp is set, so the Succeeded condition the adoption gate waits on (main.go:448) never turns true and the VM is never adopted. Separately, the Migration name is fixed ({{ .Release.Name }}) and its spec does not change when vms grows, so a completed Forklift Migration is not re-run: a VM added via helm upgrade is silently never migrated, with no error surfaced. Fix: either do not expose warm until cutover is modeled, or provide a cutover mechanism; and give added VMs a path to migrate (per-VM Migration, or detect the diff).

[MAJOR] hack/e2e-apps/vm-import.bats:1, the shipped e2e suite is never executed and would not have coverage if it were

CI runs chainsaw suites: hack/e2e-chainsaw/README.md:5 states this directory replaces the per-app bats suite that used to live in hack/e2e-apps/*.bats, and packages/core/testing/Makefile:31 runs test-chainsaw plus only the e2e-prepare-cluster / e2e-install-cozystack / e2e-test-openapi bats. Nothing globs hack/e2e-apps/. There is no vm-import chainsaw suite. Even if the bats ran, hack/e2e-install-cozystack.bats:242 enables only cozystack.external-dns-application in bundles.enabledPackages, so the opt-in forklift/vm-import CRDs are absent and applying a VMImport would fail with "no matches for kind". The e2e coverage this PR appears to add provides none. Fix: add a chainsaw suite under hack/e2e-chainsaw/ and enable the packages in the e2e install, or drop the dead bats file and state the tested surface honestly.

[MAJOR] packages/apps/vm-import/docs/scripts/adopt-vm.sh:76, the manual-adoption fallback script produces a values file that cannot adopt

Three independent defects. (a) Lines 76-79 strip a vm-disk- prefix that a Forklift disk never has and emit only name: with no dvName, so vm-instance resolves the non-existent DataVolume vm-disk-<fullname> and renders fail "Specified disk not exists in cluster" (vm.yaml:68, 77). The dvName field this same PR added for exactly this case is never emitted. (b) The generated values file (lines 120-178) has no fullnameOverride, and the script never runs the Helm ownership steps adoption.md:62-75 requires, so the release renders a second VM instead of adopting the existing one (adoption.md:80). (c) Line 167 sets cloudInitSeed: "imported-vm" while adoption.md:82, the controller (main.go:1015), and _helpers.tpl:57,60-68 all require cloudInitSeed: "" to preserve the firmware UUID, so following the script gives the adopted VM a fresh SMBIOS UUID and churns guest identity. Fix: emit dvName and fullnameOverride, apply the ownership labels/annotations, and set cloudInitSeed: "".

[MAJOR] packages/apps/vm-instance/values.schema.json:213, the fullnameOverride immutability rule does not block unsetting the field

The only guard is "rule": "self == oldSelf" (mirrored in cozyrds/vm-instance.yaml:11), with no default and not required. A CEL transition rule is evaluated only when the field is present in both old and new objects, so deleting fullnameOverride from an adopted VMInstance passes validation. The VirtualMachine name then reverts to the chart default (vm.yaml:15), Helm renames the object, prunes the running imported VM and creates a fresh one: exactly the disruptive re-roll the rule was added to prevent. Fix: make the field required on this app, or move the immutability guard to the parent object so the unset transition is covered.

[MAJOR] packages/apps/vm-instance/templates/vm.yaml:68, the new dvName field and DataVolume-vs-PVC fallback branch ship with no automated test

This rewrites the disk-rendering block of vm-instance, a core already-deployed chart backing every VM, not only imported ones. It adds the disk.dvName override and a new branch that references a bare persistentVolumeClaim when no DataVolume of the resolved name exists (vm.yaml:120-126). The only file under packages/apps/vm-instance/tests/ is hook_securitycontext_test.yaml, nothing exercises the dvName path or the PVC-fallback path, and the vm-import bats (already dead, see above) never migrates a VM so the branch is never reached in CI. The DataVolume path is preserved for existing VMs (with dvName unset $dvName resolves to vm-disk-<name>, unchanged), so this is not a confirmed regression, but a new render branch on a chart this load-bearing needs a regression test. helm-unittest can seed lookup via kubernetesProvider (sibling charts do this): add a case seeding only a PVC (assert persistentVolumeClaim.claimName) and one setting disk.dvName (assert the override is used).

[MINOR] packages/system/vm-adoption-controller/templates/clusterrole.yaml:17, cluster-wide update/patch/delete on vminstances and vmdisks are never used

The rule grants ["get","list","watch","create","update","patch","delete"] on vminstances and vmdisks, but the controller only ever Gets and Creates them; the sole Delete targets kubevirt VirtualMachines (main.go:1155) and the volume Updates target datavolumes/PVCs. Least privilege: trim vminstances/vmdisks to get, list, watch, create. A compromised controller pod otherwise has cluster-wide delete on every tenant's VMInstances and VMDisks.

[MINOR] packages/apps/vm-import/docs/examples/simple-import.yaml:22, example manifests use a non-served apiVersion

simple-import.yaml:22 and advanced-import.yaml:21 use apiVersion: apps.cozystack.io/v1, but the served version is v1alpha1 (controller at main.go:55/60, MIGRATION_GUIDE.md:262, and 34 other references in-repo). Applying either example fails with "no matches for kind VMImport in version apps.cozystack.io/v1". Fix: change both to v1alpha1.

[MINOR] packages/system/vm-adoption-controller/images/controller/main.go:905, a Multus namespace/name network reference is silently rebound to the tenant namespace

namespace/name is stripped to name (main.go:906-910), emitted as {"name": netRef}, and vm-instance re-prefixes the target namespace (vm.yaml:141). A source VM attached to a NAD in another namespace silently rebinds to a same-named NAD in the tenant, or fails to start if none exists, with no warning. Low impact in the namespace-local NAD model, but the cross-namespace intent is lost silently: at least log a warning when the stripped namespace differs from the target.

[MINOR] packages/core/platform/templates/bundles/iaas.yaml:180, enabling vm-import-application alone hangs on DependenciesNotReady

All three new packages are fully opt-in (the .optional.default helper emits the Package only when the name is in bundles.enabledPackages; .default selects the variant, not "enabled by default"). sources/vm-import-application.yaml declares dependsOn: [cozystack.forklift, cozystack.vm-adoption-controller, ...]. Because these are independent flags, an operator who enables only cozystack.vm-import-application gets a Package whose dependencies are never emitted and it stays DependenciesNotReady forever. The status is legible, so this is a usability/documentation gap: document that all three names must be enabled together, or gate the vm-import include on its deps.

[MINOR] packages/apps/vm-import/docs/MIGRATION_GUIDE.md:113, the guide says the components ship with the iaas bundle, but they are opt-in

The prerequisite is listed as "Forklift operator and the vm-import app deployed (the iaas bundle)". After this PR the iaas bundle only wires them as opt-in enabledPackages entries, so an iaas-bundle cluster does not get them by default. Update the prose to the actual enablement step.

[MINOR] packages/system/forklift-operator/templates/forklift-operator.yaml:7484, Forklift images are pinned to a rolling tag and hardcoded to quay.io

The component images are digest-pinned (good) but the tag is the rolling release-2.11. Upstream may garbage-collect old digests of a rolling tag, so the pin can die and block pulls; an immutable/dated tag or a cozystack mirror is more durable. The refs are hardcoded to quay.io rather than routed through the images-registry mirror, so air-gapped installs must mirror them explicitly. .github/renovate.json adds a manager that refreshes the digests, so this is a durability/air-gap caveat, not a broken pin.

[NIT] packages/system/vm-adoption-controller/values.yaml:23, controller.adoptionEnabled: true is consumed by nothing

Neither deployment.yaml (args: only --namespace, --watch-interval, --name-prefix, --default-instance-type, --default-preference) nor main.go reads it; the only adoption toggle the controller honors is the per-Plan annotation vm-import.cozystack.io/adoption-enabled (main.go:488). An operator setting adoptionEnabled: false reasonably believes adoption is globally off while it keeps running. Either wire the flag through or remove it.

Operational risks

  • packages/apps/vm-import/templates/plan.yaml:61, tenant self-service virt-v2v has no render-time guard. For the default (non-raw-copy) path targetNamespace is always .Release.Namespace, and dashboard-resourcemap.yaml:6 shows tenant self-service deployment is a supported shape. A tenant that follows the README deploys the virt-v2v conversion pod into its own namespace. Whether a baseline-PSA tenant namespace rejects that privileged pod depends on the pod's securityContext and the tenant PSA level, which cannot be exercised in a hermetic review, so the terminal symptom is unproven, but there is no render-time validation of the tenant+virt-v2v combination and no controller-side signal if the Migration wedges in ImageConversion. A render-time warning or fail for that combination is warranted.

Caveats

  • The charts-direct-edit cozy-invariant on packages/apps/vm-import/charts/cozy-lib is a false positive: that path is the standard symlink to packages/library/cozy-lib, not a vendored edit. No action needed.
  • Tenant isolation is sound (not a finding, recorded so it is not re-litigated): a tenant-namespace import confines the Plan targetNamespace and the target-namespace annotation to the release namespace, an admin-namespace import correctly honors a cross-namespace target, and the guards (resolveTargetNamespace + validateVMBelongsToPlan namespace check) are backed by TestResolveTargetNamespace / TestValidateVMBelongsToPlan / TestGetForkliftVMsRejectsForeignPlanClaim. A mutation that neuters the tenant-confine branch reddens the exact test, so the coverage is non-vacuous. The forged-plan-label attack (a tenant super-admin stamping a plan UID label on a raw kubevirt VM) is closed by the namespace check.
  • dashboard-resourcemap.yaml:21 combines list/watch with resourceNames, which RBAC ignores for collection verbs, so only get is effectively constrained. This matches the pre-existing pattern in other charts and is harmless if the dashboard reads named resources via get; flag only if the dashboard actually lists plans/migrations to show progress, which this Role would deny.
  • "Validated end-to-end on a live cluster" is the author's manual result and cannot be reproduced here (hermetic review, no cluster). The CI e2e does not exercise migration or adoption (see the e2e finding).
  • Render-blind surfaces not verifiable statically: the adopted VMInstance HR applying via helm-controller SSA, the CDI cross-namespace clone converging (and whether it stalls on the attached RWO source PVC, since nothing waits for clone completion before the VMInstance Create), and the same-name Forklift-VM / VMInstance handoff window. These need a live exercise.

Recommended follow-ups

  • Run cozystack-pr-test against a disposable dev cluster to exercise the real adoption path (both virt-v2v cross-namespace and raw-copy same-namespace): confirm the disk-copy lifecycle, that the source VM state is handled, the CDI clone converges, the vm-instance PVC-fallback branch renders, and that the default u1.medium / ubuntu presets resolve (an absent instance type would leave the adopted VMInstance HR Failed while the controller reports Adopted).

// confined to its own namespace: otherwise a tenant could name another tenant's
// namespace and have this cluster-privileged controller create a VMInstance and
// clone DataVolumes there (cross-tenant escalation).
func (c *AdoptionController) getTargetNamespace(ctx context.Context, namespace, planName string) string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] getTargetNamespace defaults to the Plan namespace on any Get error, not just NotFound

The Get error branch is unconditional (if err != nil { return namespace }), not apierrors.IsNotFound-gated, and the Plan was already resolved in hand at the call site so this second Get is redundant. Scenario: an admin Plan in cozy-forklift carrying vm-import.cozystack.io/target-namespace: tenant-a. On a transient Get error this returns cozy-forklift; on the virt-v2v path vm.Namespace == planNamespace, so wrapDisksAsVMDisks early-returns (no clone), the VMInstance is created in the privileged namespace, and releaseSourceVM (same-namespace branch, main.go:1146-1160) detaches the PVC ownerRefs and deletes the Forklift VM. Non-retryable, the source VM is gone. getPlanPreset (main.go:553-563) has the same swallow-and-default shape and drops the operator's chosen instanceType/profile on a transient error. Fix: propagate the error and requeue on anything that is not a real NotFound, or drop the redundant Get.


// Idempotency: if the VMInstance already exists, the create succeeded on an
// earlier pass and only the source VM is left to release.
if _, err := c.dynamicClient.Resource(vmInstanceGVR).Namespace(targetNamespace).Get(ctx, vmInstanceName, metav1.GetOptions{}); err == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Idempotency check matches a VMInstance by name only, with no identity verification

The "already created" short-circuit does a Get(vmInstanceName) in the target namespace and, on success, jumps straight to releaseSourceVM, never reading back the vm-import.cozystack.io/original-vm-name/-namespace annotations written on create (main.go:1032-1033); vmInstanceName == vm.Name. Scenario: a tenant already runs an unrelated VMInstance web, and a VMware VM named web is imported into the same namespace. The Get finds the pre-existing web, the controller treats it as its own earlier success, and releaseSourceVM detaches the import's PVC ownerRefs and deletes the imported Forklift VM. The import is silently destroyed and its disks orphaned; the unrelated VMInstance is untouched. Fix: verify the found VMInstance carries this import's identity annotations before releasing the source VM.

// controller performs the cross-namespace clone itself first, so the VMDisk
// only ever references a PVC in its own namespace.
localSrc := srcPVCName
if srcPVCNs != vmDiskNs {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Cross-namespace adoption leaks two permanent full-disk copies per disk

The virt-v2v cross-namespace path clones the imported PVC into <vmDiskName>-src (main.go:651-655), then ensureVMDisk clones that into vm-disk-<name> (main.go:627, 682). Nothing ever deletes the -src DataVolume/PVC, and the original PVC in cozy-forklift also stays. The only Delete in the controller is the kubevirt VM at main.go:1155, and RBAC could not delete volumes anyway: clusterrole.yaml:22-24 grants datavolumes no delete, clusterrole.yaml:35-37 grants PVCs no delete. Net result on the cross-namespace path is three full copies of every migrated disk, one referenced and two permanent garbage with no ownerRef for GC. The same-namespace/raw-copy path avoids this via the early return. Fix: delete the intermediate -src volume after the VMDisk clone completes (and grant the verb), or clone directly into the final target.

targetNamespace, vmInstanceName string, disks []interface{}) error {

helmReleaseName := "vm-instance-" + vmInstanceName
if targetNamespace != vm.Namespace {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Cross-namespace adoption leaves the source VM running and stamps the wrong release namespace on it

On the cross-namespace branch labelVMAsAdopted only labels the Forklift VM, never halts or deletes it (main.go:1140-1145). The source VM keeps its inherited runStrategy (main.go:767-781, default Always) and runs off the original disk next to the tenant clone, so both boot with the same guest identity and MAC. Separately, labelVMAsAdopted writes meta.helm.sh/release-namespace: <source VM namespace> (main.go:1191), the privileged namespace, for a release that actually lives in the tenant namespace: false ownership metadata that would let a future vm-instance-<name> release in cozy-forklift adopt and rewrite this VM. Fix: stop/delete the source VM on the cross-namespace path once the clone is confirmed, and omit or correct the Helm ownership annotations on the source VM.

@@ -0,0 +1,12 @@
{{- if .Values.vms }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] warm: true can never complete and a helm-upgrade that adds a VM never migrates it

The Migration is rendered once with only spec.plan.{name,namespace}; there is no spec.cutover anywhere (grep for cutover hits docs only), while warm is a real exposed value (plan.yaml:29, values.yaml:35, values.schema.json:56, advanced-import.yaml:44). A Forklift warm migration precopies indefinitely until a cutover timestamp is set, so the Succeeded condition the adoption gate waits on (main.go:448) never turns true and the VM is never adopted. Separately, the Migration name is fixed ({{ .Release.Name }}) and its spec does not change when vms grows, so a completed Forklift Migration is not re-run: a VM added via helm upgrade is silently never migrated. Fix: do not expose warm until cutover is modeled (or provide a cutover mechanism), and give added VMs a migration path.

verbs: ["get", "list", "watch"]

# Create/manage/delete VMInstances and VMDisks (imported disks become managed VMDisks)
- apiGroups: ["apps.cozystack.io"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Cluster-wide update/patch/delete on vminstances and vmdisks are never used

The rule grants [get,list,watch,create,update,patch,delete] on vminstances and vmdisks, but the controller only Gets and Creates them; the sole Delete targets kubevirt VirtualMachines (main.go:1155) and the volume Updates target datavolumes/PVCs. Least privilege: trim vminstances/vmdisks to get, list, watch, create. Otherwise a compromised controller pod has cluster-wide delete on every tenant's VMInstances and VMDisks.

thumbprint: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD"

---
apiVersion: apps.cozystack.io/v1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Example manifests use a non-served apiVersion

simple-import.yaml:22 and advanced-import.yaml:21 use apiVersion: apps.cozystack.io/v1, but the served version is v1alpha1 (controller main.go:55/60, MIGRATION_GUIDE.md:262, and 34 other in-repo references). Applying either example fails with "no matches for kind VMImport in version apps.cozystack.io/v1". Fix: change both to v1alpha1.

continue
}

// networkName format is "namespace/name" — extract just the name part

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] A Multus namespace/name network reference is silently rebound to the tenant namespace

namespace/name is stripped to name (main.go:906-910), emitted as {"name": netRef}, and vm-instance re-prefixes the target namespace (vm.yaml:141). A source VM attached to a NAD in another namespace silently rebinds to a same-named NAD in the tenant, or fails to start if none exists, with no warning. Low impact in the namespace-local NAD model, but the cross-namespace intent is lost silently: at least log a warning when the stripped namespace differs from the target.

## 3. Prerequisites

### 3.1 Cluster / platform
- Forklift operator and the `vm-import` app deployed (the `iaas` bundle).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] The guide says the components ship with the iaas bundle, but they are opt-in

The prerequisite is listed as "Forklift operator and the vm-import app deployed (the iaas bundle)". After this PR the iaas bundle only wires them as opt-in enabledPackages entries, so an iaas-bundle cluster does not get them by default. Update the prose to the actual enablement step (add the three package names to bundles.enabledPackages).

watchNamespace: ""

# Enable adoption only for VMs with specific labels
adoptionEnabled: true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] controller.adoptionEnabled is consumed by nothing

Neither deployment.yaml (args: only --namespace, --watch-interval, --name-prefix, --default-instance-type, --default-preference) nor main.go reads adoptionEnabled; the only adoption toggle the controller honors is the per-Plan annotation vm-import.cozystack.io/adoption-enabled (main.go:488). An operator setting adoptionEnabled: false reasonably believes adoption is globally off while it keeps running. Either wire the flag through or remove it. (Severity NIT.)

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

NOT LGTM

The cross-tenant isolation design is sound and well tested, but the adoption controller's Go logic has several confirmed data-destruction paths (a transient API error and a name collision both silently delete the imported VM), the disk clone chain leaks two permanent copies per disk, warm migrations can never complete, the shipped e2e suite runs nothing, and the manual adopt-vm.sh fallback produces a values file that cannot adopt. These are blocking.

Findings

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:520, getTargetNamespace defaults to the Plan namespace on any Get error, not just NotFound

The Get error branch is unconditional (if err != nil { return namespace }), it is not apierrors.IsNotFound-gated. The Plan was already resolved in hand at the call site (resolvePlan), so this second namespaced Get is redundant and only adds a failure mode. Scenario: an admin Plan in cozy-forklift carries vm-import.cozystack.io/target-namespace: tenant-a. On a transient Get error getTargetNamespace returns cozy-forklift. On the virt-v2v path vm.Namespace == planNamespace == cozy-forklift, so targetNamespace == vm.Namespace, wrapDisksAsVMDisks early-returns (src==dst, no clone), the VMInstance is created in the privileged namespace, and releaseSourceVM takes the same-namespace branch (main.go:1146-1160) that detaches the PVC ownerRefs and deletes the Forklift VM. It is non-retryable because the source VM the controller keys on is gone. getPlanPreset (main.go:553-563) has the same swallow-and-default-on-error shape and silently drops the operator's chosen instanceType/instanceProfile on a transient error. Fix: propagate the error and requeue on anything that is not a real NotFound, or drop the redundant Get and reuse the already-resolved Plan.

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:996, idempotency check matches a VMInstance by name only, with no identity verification

The "already created" short-circuit does a Get(vmInstanceName) in the target namespace and, on success, jumps straight to releaseSourceVM. It never reads back the vm-import.cozystack.io/original-vm-name / -namespace annotations it writes on create (main.go:1032-1033), and vmInstanceName == vm.Name. Scenario: a tenant already runs an unrelated VMInstance web, and a VMware VM named web is imported into the same namespace. The Get finds the pre-existing web, the controller treats it as its own earlier success, and releaseSourceVM detaches the import's PVC ownerRefs and deletes the imported Forklift VM. The import is silently destroyed and its disks orphaned, while the unrelated VMInstance is untouched. Fix: verify the found VMInstance carries this import's identity annotations before releasing the source VM.

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:651, cross-namespace adoption leaks two permanent full-disk copies per disk

The virt-v2v cross-namespace path clones the imported PVC into <vmDiskName>-src (main.go:651-655), then ensureVMDisk clones that into vm-disk-<name> (main.go:627, 682). Nothing ever deletes the -src DataVolume/PVC, and the original PVC in cozy-forklift also stays (the source VM is only labeled). The only Delete in the controller is the kubevirt VM at main.go:1155, and RBAC could not delete volumes anyway: clusterrole.yaml:22-24 grants datavolumes no delete and clusterrole.yaml:35-37 grants PVCs no delete. Net result on the cross-namespace path is three full copies of every migrated disk on replicated storage, one referenced and two permanent garbage with no ownerRef for GC. The same-namespace/raw-copy path correctly avoids this via the early return. Fix: delete the intermediate -src volume after the VMDisk clone completes (and grant the verb), or clone directly into the final target.

[MAJOR] packages/system/vm-adoption-controller/images/controller/main.go:1140, cross-namespace adoption leaves the source VM running and stamps the wrong release namespace on it

On the cross-namespace branch labelVMAsAdopted only labels the Forklift VM, it never halts or deletes it (main.go:1140-1145). The source VM keeps its inherited runStrategy (extracted at main.go:767-781, default Always) and keeps running off the original disk next to the tenant clone, so both boot with the same guest identity and MAC. Separately, labelVMAsAdopted writes meta.helm.sh/release-namespace: <source VM namespace> (main.go:1191), the privileged namespace, for a release that actually lives in the tenant namespace. That is false ownership metadata: a future vm-instance-<name> release in cozy-forklift could adopt and rewrite this VM. Fix: stop/delete the source VM on the cross-namespace path once the clone is confirmed, and either omit the Helm ownership annotations on the source VM or set them to the real release namespace.

[MAJOR] packages/apps/vm-import/templates/migration.yaml:1, warm: true can never complete and a helm-upgrade that adds a VM never migrates it

The Migration is rendered once with only spec.plan.{name,namespace}, there is no spec.cutover anywhere (grep for cutover in packages/apps/vm-import/ hits docs only). warm is a real exposed value (plan.yaml:29, values.yaml:35, values.schema.json:56, advanced-import.yaml:44). A Forklift warm migration precopies indefinitely until a cutover timestamp is set, so the Succeeded condition the adoption gate waits on (main.go:448) never turns true and the VM is never adopted. Separately, the Migration name is fixed ({{ .Release.Name }}) and its spec does not change when vms grows, so a completed Forklift Migration is not re-run: a VM added via helm upgrade is silently never migrated, with no error surfaced. Fix: either do not expose warm until cutover is modeled, or provide a cutover mechanism; and give added VMs a path to migrate (per-VM Migration, or detect the diff).

[MAJOR] hack/e2e-apps/vm-import.bats:1, the shipped e2e suite is never executed and would not have coverage if it were

CI runs chainsaw suites: hack/e2e-chainsaw/README.md:5 states this directory replaces the per-app bats suite that used to live in hack/e2e-apps/*.bats, and packages/core/testing/Makefile:31 runs test-chainsaw plus only the e2e-prepare-cluster / e2e-install-cozystack / e2e-test-openapi bats. Nothing globs hack/e2e-apps/. There is no vm-import chainsaw suite. Even if the bats ran, hack/e2e-install-cozystack.bats:242 enables only cozystack.external-dns-application in bundles.enabledPackages, so the opt-in forklift/vm-import CRDs are absent and applying a VMImport would fail with "no matches for kind". The e2e coverage this PR appears to add provides none. Fix: add a chainsaw suite under hack/e2e-chainsaw/ and enable the packages in the e2e install, or drop the dead bats file and state the tested surface honestly.

[MAJOR] packages/apps/vm-import/docs/scripts/adopt-vm.sh:76, the manual-adoption fallback script produces a values file that cannot adopt

Three independent defects. (a) Lines 76-79 strip a vm-disk- prefix that a Forklift disk never has and emit only name: with no dvName, so vm-instance resolves the non-existent DataVolume vm-disk-<fullname> and renders fail "Specified disk not exists in cluster" (vm.yaml:68, 77). The dvName field this same PR added for exactly this case is never emitted. (b) The generated values file (lines 120-178) has no fullnameOverride, and the script never runs the Helm ownership steps adoption.md:62-75 requires, so the release renders a second VM instead of adopting the existing one (adoption.md:80). (c) Line 167 sets cloudInitSeed: "imported-vm" while adoption.md:82, the controller (main.go:1015), and _helpers.tpl:57,60-68 all require cloudInitSeed: "" to preserve the firmware UUID, so following the script gives the adopted VM a fresh SMBIOS UUID and churns guest identity. Fix: emit dvName and fullnameOverride, apply the ownership labels/annotations, and set cloudInitSeed: "".

[MAJOR] packages/apps/vm-instance/values.schema.json:213, the fullnameOverride immutability rule does not block unsetting the field

The only guard is "rule": "self == oldSelf" (mirrored in cozyrds/vm-instance.yaml:11), with no default and not required. A CEL transition rule is evaluated only when the field is present in both old and new objects, so deleting fullnameOverride from an adopted VMInstance passes validation. The VirtualMachine name then reverts to the chart default (vm.yaml:15), Helm renames the object, prunes the running imported VM and creates a fresh one: exactly the disruptive re-roll the rule was added to prevent. Fix: make the field required on this app, or move the immutability guard to the parent object so the unset transition is covered.

[MAJOR] packages/apps/vm-instance/templates/vm.yaml:68, the new dvName field and DataVolume-vs-PVC fallback branch ship with no automated test

This rewrites the disk-rendering block of vm-instance, a core already-deployed chart backing every VM, not only imported ones. It adds the disk.dvName override and a new branch that references a bare persistentVolumeClaim when no DataVolume of the resolved name exists (vm.yaml:120-126). The only file under packages/apps/vm-instance/tests/ is hook_securitycontext_test.yaml, nothing exercises the dvName path or the PVC-fallback path, and the vm-import bats (already dead, see above) never migrates a VM so the branch is never reached in CI. The DataVolume path is preserved for existing VMs (with dvName unset $dvName resolves to vm-disk-<name>, unchanged), so this is not a confirmed regression, but a new render branch on a chart this load-bearing needs a regression test. helm-unittest can seed lookup via kubernetesProvider (sibling charts do this): add a case seeding only a PVC (assert persistentVolumeClaim.claimName) and one setting disk.dvName (assert the override is used).

[MINOR] packages/system/vm-adoption-controller/templates/clusterrole.yaml:17, cluster-wide update/patch/delete on vminstances and vmdisks are never used

The rule grants ["get","list","watch","create","update","patch","delete"] on vminstances and vmdisks, but the controller only ever Gets and Creates them; the sole Delete targets kubevirt VirtualMachines (main.go:1155) and the volume Updates target datavolumes/PVCs. Least privilege: trim vminstances/vmdisks to get, list, watch, create. A compromised controller pod otherwise has cluster-wide delete on every tenant's VMInstances and VMDisks.

[MINOR] packages/apps/vm-import/docs/examples/simple-import.yaml:22, example manifests use a non-served apiVersion

simple-import.yaml:22 and advanced-import.yaml:21 use apiVersion: apps.cozystack.io/v1, but the served version is v1alpha1 (controller at main.go:55/60, MIGRATION_GUIDE.md:262, and 34 other references in-repo). Applying either example fails with "no matches for kind VMImport in version apps.cozystack.io/v1". Fix: change both to v1alpha1.

[MINOR] packages/system/vm-adoption-controller/images/controller/main.go:905, a Multus namespace/name network reference is silently rebound to the tenant namespace

namespace/name is stripped to name (main.go:906-910), emitted as {"name": netRef}, and vm-instance re-prefixes the target namespace (vm.yaml:141). A source VM attached to a NAD in another namespace silently rebinds to a same-named NAD in the tenant, or fails to start if none exists, with no warning. Low impact in the namespace-local NAD model, but the cross-namespace intent is lost silently: at least log a warning when the stripped namespace differs from the target.

[MINOR] packages/core/platform/templates/bundles/iaas.yaml:180, enabling vm-import-application alone hangs on DependenciesNotReady

All three new packages are fully opt-in (the .optional.default helper emits the Package only when the name is in bundles.enabledPackages; .default selects the variant, not "enabled by default"). sources/vm-import-application.yaml declares dependsOn: [cozystack.forklift, cozystack.vm-adoption-controller, ...]. Because these are independent flags, an operator who enables only cozystack.vm-import-application gets a Package whose dependencies are never emitted and it stays DependenciesNotReady forever. The status is legible, so this is a usability/documentation gap: document that all three names must be enabled together, or gate the vm-import include on its deps.

[MINOR] packages/apps/vm-import/docs/MIGRATION_GUIDE.md:113, the guide says the components ship with the iaas bundle, but they are opt-in

The prerequisite is listed as "Forklift operator and the vm-import app deployed (the iaas bundle)". After this PR the iaas bundle only wires them as opt-in enabledPackages entries, so an iaas-bundle cluster does not get them by default. Update the prose to the actual enablement step.

[MINOR] packages/system/forklift-operator/templates/forklift-operator.yaml:7484, Forklift images are pinned to a rolling tag and hardcoded to quay.io

The component images are digest-pinned (good) but the tag is the rolling release-2.11. Upstream may garbage-collect old digests of a rolling tag, so the pin can die and block pulls; an immutable/dated tag or a cozystack mirror is more durable. The refs are hardcoded to quay.io rather than routed through the images-registry mirror, so air-gapped installs must mirror them explicitly. .github/renovate.json adds a manager that refreshes the digests, so this is a durability/air-gap caveat, not a broken pin.

[NIT] packages/system/vm-adoption-controller/values.yaml:23, controller.adoptionEnabled: true is consumed by nothing

Neither deployment.yaml (args: only --namespace, --watch-interval, --name-prefix, --default-instance-type, --default-preference) nor main.go reads it; the only adoption toggle the controller honors is the per-Plan annotation vm-import.cozystack.io/adoption-enabled (main.go:488). An operator setting adoptionEnabled: false reasonably believes adoption is globally off while it keeps running. Either wire the flag through or remove it.

Operational risks

  • packages/apps/vm-import/templates/plan.yaml:61, tenant self-service virt-v2v has no render-time guard. For the default (non-raw-copy) path targetNamespace is always .Release.Namespace, and dashboard-resourcemap.yaml:6 shows tenant self-service deployment is a supported shape. A tenant that follows the README deploys the virt-v2v conversion pod into its own namespace. Whether a baseline-PSA tenant namespace rejects that privileged pod depends on the pod's securityContext and the tenant PSA level, which cannot be exercised in a hermetic review, so the terminal symptom is unproven, but there is no render-time validation of the tenant+virt-v2v combination and no controller-side signal if the Migration wedges in ImageConversion. A render-time warning or fail for that combination is warranted.

Caveats

  • The charts-direct-edit cozy-invariant on packages/apps/vm-import/charts/cozy-lib is a false positive: that path is the standard symlink to packages/library/cozy-lib, not a vendored edit. No action needed.
  • Tenant isolation is sound (not a finding, recorded so it is not re-litigated): a tenant-namespace import confines the Plan targetNamespace and the target-namespace annotation to the release namespace, an admin-namespace import correctly honors a cross-namespace target, and the guards (resolveTargetNamespace + validateVMBelongsToPlan namespace check) are backed by TestResolveTargetNamespace / TestValidateVMBelongsToPlan / TestGetForkliftVMsRejectsForeignPlanClaim. A mutation that neuters the tenant-confine branch reddens the exact test, so the coverage is non-vacuous. The forged-plan-label attack (a tenant super-admin stamping a plan UID label on a raw kubevirt VM) is closed by the namespace check.
  • dashboard-resourcemap.yaml:21 combines list/watch with resourceNames, which RBAC ignores for collection verbs, so only get is effectively constrained. This matches the pre-existing pattern in other charts and is harmless if the dashboard reads named resources via get; flag only if the dashboard actually lists plans/migrations to show progress, which this Role would deny.
  • "Validated end-to-end on a live cluster" is the author's manual result and cannot be reproduced here (hermetic review, no cluster). The CI e2e does not exercise migration or adoption (see the e2e finding).
  • Render-blind surfaces not verifiable statically: the adopted VMInstance HR applying via helm-controller SSA, the CDI cross-namespace clone converging (and whether it stalls on the attached RWO source PVC, since nothing waits for clone completion before the VMInstance Create), and the same-name Forklift-VM / VMInstance handoff window. These need a live exercise.

Recommended follow-ups

  • Run cozystack-pr-test against a disposable dev cluster to exercise the real adoption path (both virt-v2v cross-namespace and raw-copy same-namespace): confirm the disk-copy lifecycle, that the source VM state is handled, the CDI clone converges, the vm-instance PVC-fallback branch renders, and that the default u1.medium / ubuntu presets resolve (an absent instance type would leave the adopted VMInstance HR Failed while the controller reports Adopted).

// confined to its own namespace: otherwise a tenant could name another tenant's
// namespace and have this cluster-privileged controller create a VMInstance and
// clone DataVolumes there (cross-tenant escalation).
func (c *AdoptionController) getTargetNamespace(ctx context.Context, namespace, planName string) string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] getTargetNamespace defaults to the Plan namespace on any Get error, not just NotFound

The Get error branch is unconditional (if err != nil { return namespace }), not apierrors.IsNotFound-gated, and the Plan was already resolved in hand at the call site so this second Get is redundant. Scenario: an admin Plan in cozy-forklift carrying vm-import.cozystack.io/target-namespace: tenant-a. On a transient Get error this returns cozy-forklift; on the virt-v2v path vm.Namespace == planNamespace, so wrapDisksAsVMDisks early-returns (no clone), the VMInstance is created in the privileged namespace, and releaseSourceVM (same-namespace branch, main.go:1146-1160) detaches the PVC ownerRefs and deletes the Forklift VM. Non-retryable, the source VM is gone. getPlanPreset (main.go:553-563) has the same swallow-and-default shape and drops the operator's chosen instanceType/profile on a transient error. Fix: propagate the error and requeue on anything that is not a real NotFound, or drop the redundant Get.


// Idempotency: if the VMInstance already exists, the create succeeded on an
// earlier pass and only the source VM is left to release.
if _, err := c.dynamicClient.Resource(vmInstanceGVR).Namespace(targetNamespace).Get(ctx, vmInstanceName, metav1.GetOptions{}); err == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Idempotency check matches a VMInstance by name only, with no identity verification

The "already created" short-circuit does a Get(vmInstanceName) in the target namespace and, on success, jumps straight to releaseSourceVM, never reading back the vm-import.cozystack.io/original-vm-name/-namespace annotations written on create (main.go:1032-1033); vmInstanceName == vm.Name. Scenario: a tenant already runs an unrelated VMInstance web, and a VMware VM named web is imported into the same namespace. The Get finds the pre-existing web, the controller treats it as its own earlier success, and releaseSourceVM detaches the import's PVC ownerRefs and deletes the imported Forklift VM. The import is silently destroyed and its disks orphaned; the unrelated VMInstance is untouched. Fix: verify the found VMInstance carries this import's identity annotations before releasing the source VM.

// controller performs the cross-namespace clone itself first, so the VMDisk
// only ever references a PVC in its own namespace.
localSrc := srcPVCName
if srcPVCNs != vmDiskNs {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Cross-namespace adoption leaks two permanent full-disk copies per disk

The virt-v2v cross-namespace path clones the imported PVC into <vmDiskName>-src (main.go:651-655), then ensureVMDisk clones that into vm-disk-<name> (main.go:627, 682). Nothing ever deletes the -src DataVolume/PVC, and the original PVC in cozy-forklift also stays. The only Delete in the controller is the kubevirt VM at main.go:1155, and RBAC could not delete volumes anyway: clusterrole.yaml:22-24 grants datavolumes no delete, clusterrole.yaml:35-37 grants PVCs no delete. Net result on the cross-namespace path is three full copies of every migrated disk, one referenced and two permanent garbage with no ownerRef for GC. The same-namespace/raw-copy path avoids this via the early return. Fix: delete the intermediate -src volume after the VMDisk clone completes (and grant the verb), or clone directly into the final target.

targetNamespace, vmInstanceName string, disks []interface{}) error {

helmReleaseName := "vm-instance-" + vmInstanceName
if targetNamespace != vm.Namespace {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Cross-namespace adoption leaves the source VM running and stamps the wrong release namespace on it

On the cross-namespace branch labelVMAsAdopted only labels the Forklift VM, never halts or deletes it (main.go:1140-1145). The source VM keeps its inherited runStrategy (main.go:767-781, default Always) and runs off the original disk next to the tenant clone, so both boot with the same guest identity and MAC. Separately, labelVMAsAdopted writes meta.helm.sh/release-namespace: <source VM namespace> (main.go:1191), the privileged namespace, for a release that actually lives in the tenant namespace: false ownership metadata that would let a future vm-instance-<name> release in cozy-forklift adopt and rewrite this VM. Fix: stop/delete the source VM on the cross-namespace path once the clone is confirmed, and omit or correct the Helm ownership annotations on the source VM.

@@ -0,0 +1,12 @@
{{- if .Values.vms }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] warm: true can never complete and a helm-upgrade that adds a VM never migrates it

The Migration is rendered once with only spec.plan.{name,namespace}; there is no spec.cutover anywhere (grep for cutover hits docs only), while warm is a real exposed value (plan.yaml:29, values.yaml:35, values.schema.json:56, advanced-import.yaml:44). A Forklift warm migration precopies indefinitely until a cutover timestamp is set, so the Succeeded condition the adoption gate waits on (main.go:448) never turns true and the VM is never adopted. Separately, the Migration name is fixed ({{ .Release.Name }}) and its spec does not change when vms grows, so a completed Forklift Migration is not re-run: a VM added via helm upgrade is silently never migrated. Fix: do not expose warm until cutover is modeled (or provide a cutover mechanism), and give added VMs a migration path.

verbs: ["get", "list", "watch"]

# Create/manage/delete VMInstances and VMDisks (imported disks become managed VMDisks)
- apiGroups: ["apps.cozystack.io"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Cluster-wide update/patch/delete on vminstances and vmdisks are never used

The rule grants [get,list,watch,create,update,patch,delete] on vminstances and vmdisks, but the controller only Gets and Creates them; the sole Delete targets kubevirt VirtualMachines (main.go:1155) and the volume Updates target datavolumes/PVCs. Least privilege: trim vminstances/vmdisks to get, list, watch, create. Otherwise a compromised controller pod has cluster-wide delete on every tenant's VMInstances and VMDisks.

thumbprint: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD"

---
apiVersion: apps.cozystack.io/v1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Example manifests use a non-served apiVersion

simple-import.yaml:22 and advanced-import.yaml:21 use apiVersion: apps.cozystack.io/v1, but the served version is v1alpha1 (controller main.go:55/60, MIGRATION_GUIDE.md:262, and 34 other in-repo references). Applying either example fails with "no matches for kind VMImport in version apps.cozystack.io/v1". Fix: change both to v1alpha1.

continue
}

// networkName format is "namespace/name" — extract just the name part

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] A Multus namespace/name network reference is silently rebound to the tenant namespace

namespace/name is stripped to name (main.go:906-910), emitted as {"name": netRef}, and vm-instance re-prefixes the target namespace (vm.yaml:141). A source VM attached to a NAD in another namespace silently rebinds to a same-named NAD in the tenant, or fails to start if none exists, with no warning. Low impact in the namespace-local NAD model, but the cross-namespace intent is lost silently: at least log a warning when the stripped namespace differs from the target.

## 3. Prerequisites

### 3.1 Cluster / platform
- Forklift operator and the `vm-import` app deployed (the `iaas` bundle).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] The guide says the components ship with the iaas bundle, but they are opt-in

The prerequisite is listed as "Forklift operator and the vm-import app deployed (the iaas bundle)". After this PR the iaas bundle only wires them as opt-in enabledPackages entries, so an iaas-bundle cluster does not get them by default. Update the prose to the actual enablement step (add the three package names to bundles.enabledPackages).

watchNamespace: ""

# Enable adoption only for VMs with specific labels
adoptionEnabled: true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] controller.adoptionEnabled is consumed by nothing

Neither deployment.yaml (args: only --namespace, --watch-interval, --name-prefix, --default-instance-type, --default-preference) nor main.go reads adoptionEnabled; the only adoption toggle the controller honors is the per-Plan annotation vm-import.cozystack.io/adoption-enabled (main.go:488). An operator setting adoptionEnabled: false reasonably believes adoption is globally off while it keeps running. Either wire the flag through or remove it. (Severity NIT.)

…imported VM

getTargetNamespace and getPlanPreset re-fetched the Plan the caller had
already resolved; a transient (non-NotFound) Get error defaulted the target to
the Plan's own namespace, collapsing a cross-namespace adoption into an
in-place one and deleting the source VM non-retryably. Read the already-
resolved Plan instead, which removes the failure mode entirely.

The idempotency short-circuit matched an existing VMInstance by name only
(vmInstanceName == vm.Name), so a tenant's unrelated VMInstance that merely
shared an imported VM's name made the controller release -- and delete -- the
imported VM while leaving the stranger's VMInstance untouched. Verify the found
VMInstance carries this import's identity annotations before releasing the
source VM.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
…ted VMs, trim RBAC

On the cross-namespace path labelVMAsAdopted wrote the source VM's own
(privileged) namespace as meta.helm.sh/release-namespace, so a future
vm-instance release created in that privileged namespace could adopt and
rewrite the VM. Record the actual adoption-target namespace instead.

The controller only gets and creates VMInstances and VMDisks, so drop the
unused update/patch/delete verbs from its ClusterRole (least privilege).
Remove the dead controller.adoptionEnabled value: nothing reads it, the real
toggle is the per-Plan vm-import.cozystack.io/adoption-enabled annotation.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
…fallback

The vm-import work rewrote the disk-rendering block of vm-instance (a core
chart backing every VM) to add a per-disk dvName override and a branch that
references a bare PersistentVolumeClaim when no DataVolume of the resolved name
exists. Neither path had a test. Add helm-unittest cases for the dvName
override, the unchanged vm-disk-<name> default, and the PVC fallback.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
The Migration is rendered once with only spec.plan and no spec.cutover, so a
Forklift warm migration precopies indefinitely and the Succeeded condition the
adoption controller waits on never turns true -- the VM is never adopted.
Remove the warm value, schema property, template field, examples and docs
until a cutover mechanism is modeled.

Also fix the example manifests' non-served apiVersion (apps.cozystack.io/v1 ->
v1alpha1) and correct the MIGRATION_GUIDE prose: the forklift,
vm-adoption-controller and vm-import-application packages are opt-in
bundles.enabledPackages entries, not shipped with the iaas bundle by default.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
…e is dropped on adoption

The network mapping strips a networkName's namespace/ prefix and vm-instance
re-adds the target namespace, so a source VM attached to a NAD in another
namespace silently rebinds to a same-named NAD in the adoption target -- or
fails to start if none exists -- with no signal. Log a warning naming the
dropped namespace.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
CI runs chainsaw suites; hack/e2e-apps/*.bats is no longer globbed by any
runner (packages/core/testing/Makefile), and even if it ran the e2e install
enables only external-dns so the opt-in vm-import CRDs are absent. The file
provided zero coverage and misrepresented the tested surface. The honest
automated coverage is helm-unittest plus the controller's go test.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
…tually adopts

The fallback script emitted disks with no dvName (so vm-instance looked up a
nonexistent vm-disk-<name> and failed), no fullnameOverride and no Helm
ownership step (so the release rendered a second VM instead of adopting), and
cloudInitSeed: imported-vm (churning the firmware UUID). Emit dvName per disk,
add fullnameOverride plus the Helm ownership label/annotations, and set
cloudInitSeed to empty so the existing-VM lookup preserves the UUID. Also drop
a fail-open || true in the summary.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
@kvaps

Copy link
Copy Markdown
Member

Thanks — worked through the review. Addressed on the branch:

Data-destruction paths:

  • getTargetNamespace/getPlanPreset now read the Plan adoptVM already resolved instead of re-fetching it, so a transient (non-NotFound) Get can no longer default the target to the Plan namespace, collapse a cross-namespace adoption into an in-place one and delete the source VM.
  • The idempotency short-circuit now verifies the existing VMInstance carries this import's original-vm-name/-namespace annotations before releasing the source VM; a name collision with an unrelated VMInstance is refused. A new test covers it.
  • labelVMAsAdopted records the real adoption-target namespace as meta.helm.sh/release-namespace, not the source VM's privileged namespace.

Charts / e2e / script:

  • Dropped the warm option entirely (value, schema, template, examples, docs) — it can never complete without a cutover model.
  • Removed the dead hack/e2e-apps/vm-import.bats (nothing globs it); the honest automated coverage is helm-unittest plus the controller's go test.
  • adopt-vm.sh now emits dvName per disk, fullnameOverride + the Helm ownership step, and cloudInitSeed: "", so it adopts instead of rendering a second VM.
  • Added helm-unittest for the vm-instance dvName override and the DataVolume/PVC fallback branch.

Minors: RBAC trimmed to get/list/watch/create on vminstances/vmdisks; example apiVersionv1alpha1; MIGRATION_GUIDE enablement prose fixed (all three packages opt-in together); dropped the dead controller.adoptionEnabled; warn when a cross-namespace NAD reference is silently rebound.

Follow-up (not in this push): the two cross-namespace clone-lifecycle findings — the leaked -src/original disk copies and the source VM left running on the cross-namespace path. Both need the controller to wait for the vm-disk-<name> clone to reach Succeeded before deleting the intermediate -src, the original PVC and the source VM (plus a delete verb on datavolumes/PVCs). That's a reconcile-loop lifecycle change rather than a spot fix, so I'll land it separately.

go build/test/vet/gofmt and helm-unittest (vm-import, vm-instance) are green.

These fixes were prepared with AI assistance and reviewed before pushing.

## @field {*SourceUpload} [upload] - Upload local image.
## @field {*SourceHTTP} [http] - Download image from an HTTP source.
## @field {*SourceDisk} [disk] - Clone an existing vm-disk.
## @field {*SourcePVC} [pvc] - Clone an existing PersistentVolumeClaim.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should a user be exposed to the underlying core k8s object? I think that's breaking the isolation supposed to exist between a tenant of Cozystack and the somewhat lower level API of k8s.

@lllamnyp Timofei Larkin (lllamnyp) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the Forklift integration itself is solid work, the templates are careful, and the guard comments explaining why each namespace check exists made this much easier to review. My comments are scoped to the tenant-facing API surface rather than the implementation, which others have covered thoroughly.

Blocking

vddkInitImage and virtV2vImage let a tenant name a container image the cluster will run. (packages/apps/vm-import/values.yaml:12,22templates/provider.yaml:15, templates/plan.yaml:32) This is a hard no in Cozystack: no arbitrary code runs in the cluster without a virtualization boundary. virtV2vImage is worse than a generic image field because it names the conversion pod, which values.yaml:18 correctly describes as privileged. Neither field should be reachable from a tenant-supplied value. Both look like platform configuration — a cluster-level setting on the Forklift package, or a fixed reference in the chart, with the VDDK image built and pinned by the platform rather than named per-import.

Fields that only work with cluster-admin assistance should not be on the tenant API at all. sourceSecretName is required (values.schema.json:156-159), but no tenant access level can create a Secret: packages/system/cozystack-basics/templates/clusterroles.yaml grants the core group only pods, services, persistentvolumes, endpoints, events, resourcequotas at view/use (:99-110) and adds delete on those same five at admin (:216-224); secrets appears in no tenant role, and core.cozystack.io/tenantsecrets is get,list,watch only (:47-50, :188-192). So a tenant cannot fill in a valid VMImport without an admin creating the Secret out of band. migrationHosts[].secretName (values.yaml:77) has the same problem. Unlike postgres, which lets credentials come in through values and materializes the Secret itself, vm-import offers no in-values path — the field a tenant can't satisfy is the only one there is.

The same rule catches tenantNamespace (values.yaml:37-38) and networkMap[].destinationNamespace (values.yaml:54). Both are documented as honored only outside a tenant-* namespace, and both templates silently coerce them back (plan.yaml:7-11, networkmap.yaml:26-30). A field that every tenant can set, that is silently ignored for every tenant, is worse than an absent one.

The minimal standard for adoption is the clean automated path: the import produces managed Cozystack objects, with nothing left for a human to wire up by name. Right now that holds on the cross-namespace path — ensureVMDisk (main.go:625-685) creates a real VMDisk — but not on the same-namespace one, where wrapDisksAsVMDisks returns early (main.go:701-704) and the adopted VMInstance points at raw Forklift PVCs. Those disks have no Cozystack representation: they are not VMDisks, can't be resized, cloned, or backed up through the VMDisk API, and the imported-0 values land in a disks[].name field whose x-cozystack-options {source: vmdisk} picker will never contain them.

That gap is what put three system-internal knobs onto the tenant API, and I'd like to see all three come back off once it closes:

  • VMDisk.source.pvc — a tenant has no way to create or name a PVC, and cloning a Cozystack-managed disk is already source.disk. In this PR the only caller is the adoption controller.
  • VMInstance.disks[].dvName — with the persistentVolumeClaim fallback (vm.yaml:120-127) this resolves to any PVC in the namespace by name, so a tenant can attach the volume behind a Postgres cluster or a Bucket straight into a VM. Separate from the test-coverage point already raised: even fully tested, this is more reach than the field needs. If it has to stay for now, constraining it to vm-disk-* would close the hole.
  • VMInstance.fullnameOverride — reaches the schema through a yq -i line in packages/apps/vm-instance/Makefile:13 that runs after cozyvalues-gen, so it exists in values.schema.json and the ApplicationDefinition but not in values.yaml, README.md, or api/apps/v1alpha1/vminstance/types.go. The documented VMInstance API and the one the dashboard renders have quietly diverged, and the field's own description says it is there for the adoption controller.

Worth settling alongside this: whether VMImport is a tenant application at all. It is registered in the tenant catalog under IaaS (packages/system/vm-import-rd/cozyrds/vm-import.yaml:20-25), yet its default path needs the privileged conversion pod that, per plan.yaml:60-64, a baseline tenant forbids — in a tenant namespace, "this namespace" is the tenant's own. If the answer is admin-only, that should be structural rather than five paragraphs of "ignored when you set it."

Medium

Given the size of the new surface — a new app, a new cluster-scoped controller, a new ApplicationDefinition, and changes to two existing tenant APIs — this really belongs in the community repo as a design proposal first. The API questions above are design questions, and settling them there is cheaper than iterating on an 81-file PR.

Minor

The VMImport ApplicationDefinition still declares warm: packages/system/vm-import-rd/cozyrds/vm-import.yaml:11 (openAPISchema) and :30 (keysOrder). The field is gone from values.yaml and values.schema.json, and hack/update-crd.sh regenerates both from the chart — a make generate in packages/apps/vm-import should clear it. As it stands the tenant API advertises a migration mode the chart ignores.

storageMap[].storageClass (values.yaml:65) and networkMap[].destinationName (values.yaml:53) are the sanctioned kind of exposure but lack @x-cozystack-options {source: storageclass} / {source: network}, so the tenant types cluster object names blind where every comparable field offers a picker.

main.go:930-941 sets spec.firmware on the VMInstance, but firmware is in neither values.yaml nor values.schema.json for vm-instance, so structural pruning drops it and UEFI guests silently adopt as BIOS. The code comment points at #3002 — worth making that a stated merge-order dependency rather than a comment.

Aleksander (keldranase) pushed a commit to keldranase/cozystack that referenced this pull request Aug 20, 2026
The fork-PR e2e lane never ran. `resolve` looked the PR up with
`listPullRequestsAssociatedWithCommit` against the base repo, and that
endpoint returns nothing for a fork PR head: the commit reaches
cozystack/cozystack only as `refs/pull/<n>/head`, and the association
index does not walk `refs/pull/*`. So every fork PR fail-closed on
"expected exactly one open PR for <sha>, found 0", stamped a red
required "E2E Tests" its author could not clear by pushing, and skipped
publish/e2e/report.

It resolves same-repo heads correctly, which is why the hole stayed
invisible — same-repo PRs get "E2E Tests" from pull-requests.yaml and
never enter this workflow. Of 935 runs, 930 skipped as designed and all
5 that reached `resolve` stopped there; three on `found 0`, and one of
those reds was later overwritten by a mirrored same-repo run, which hid
it again.

Enumerate the base repo's open pull requests instead and match on the
head SHA. Same trust properties as the call it replaces — plain
base-repo API data the fork cannot forge — and `state: 'open'` at the
source keeps the rule that a closed or merged PR never counts. The
exactly-one guard and the head_sha match are unchanged, so a stacked
lower PR still passes and two PRs genuinely at the same head still fail
closed. Pagination stops being a corner case and becomes load-bearing:
the repo has 180 open PRs, and the fork PR that surfaced this sits on
page two.

Verified against the live API for the failing case: PR cozystack#1982 at
984936f now resolves to exactly one match, with labels present so the
full-e2e override still reads. actionlint, zizmor and `node --check`
on the extracted script all pass.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
… behind

Upstream publishes these images only under rolling channel tags, so every
rebuild of release-2.11 garbage-collects the digest we pinned. Fourteen of the
sixteen pins were already dead: the operator image failed with 'not found' and
the whole package could not install. The two that survived are exactly the two
pinned to dated tags, forklift-console-plugin:release-v2.6.7 and
forklift-must-gather:v2.7.1.

Re-pin the fourteen to the digests their tags resolve to today, in the Makefile
and the rendered manifest together. Verified by installing the package on a live
cluster: the operator reaches Running and reconciles its operand.

This buys working installs, not durability. Pinning a digest against a tag that
moves cannot be durable, and there are no immutable tags upstream to pin
instead, so the lasting fix is mirroring these images the way the platform
mirrors everything else.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
VMware imports need the proprietary VDDK image, which Cozystack can neither
ship nor mirror. Deliver it the way every other platform-wide setting travels:
an operator sets the vddk-image key in the cozystack ConfigMap (or
migration.vddkImage in the platform values) and it lands in the _cluster map
that every app already receives, alongside root-host and the wildcard Secret
name.

Only a reference rides the channel, never a credential -- the same rule the
wildcard-certificate block states. Empty is the normal state and means the
VMware path is unavailable, not broken.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
A tenant naming a container image the cluster will run is not something
Cozystack permits, and virtV2vImage is the worse of the two because it names
the privileged conversion pod. Neither is a tenant decision.

vddkInitImage becomes platform configuration read from the _cluster channel. If
the operator has not configured it, a vsphere VMImport now fails at render with
a message naming the missing setting, so the tenant learns at order time rather
than partway through a transfer. virtV2vImage is dropped outright; Forklift
falls back to its own default and the operator pins it through the Forklift
package.

Regenerating the schema, README and ApplicationDefinition also clears the stale
warm advertisement the generated ApplicationDefinition still carried after the
option was removed from the chart.

Adds a provider test suite covering both refusal paths and the configured one.

BREAKING CHANGE: VMImport no longer accepts vddkInitImage or virtV2vImage. The
VDDK image is now set once per cluster via the vddk-image key in the cozystack
ConfigMap.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
@kvaps

Copy link
Copy Markdown
Member

Closing in favour of #3931, which implements the redesigned tenant API described in cozystack/community#62.

The mechanism this PR established carries over; what changed is the tenant-facing surface. Review here established that several fields could not stay on a tenant API — container images the cluster would run, and Secret references no tenant access level can satisfy — and reworking that in place would have meant iterating on an 80-file PR. The new API is a pair of CRDs in migration.cozystack.io, reconciled from the core controller.

Your work is carried into #3931 rather than restarted. The Forklift packaging — operator, ForkliftController CR, digest pinning — moves over, and you are credited as co-author there (e355a741), on the operational documentation (bda9ba25), and on the corrections that came out of your real-vCenter experience (a6af000a).

The lab findings from this PR are what shaped the new design and could not have been found from code review: the plan label carrying the Plan UID rather than its name, Forklift transferring through a CDI volume populator so the disk arrives as a populated PVC owned by the VM it creates, and the vCenter privilege set. The migration that destroyed its own disk is the direct reason the new API hands back VMDisk and VMInstance objects with no ownerReference at all.

Thank you for carrying this since February, and for the lab work behind it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/virtualization Issues or PRs related to virtualization (kubevirt, cdi, vmi, vm-import) kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants