Skip to content

Document possible migration from v1 Composition Claims to v2 style namespaced XRs #1017

@fhochleitner

Description

@fhochleitner

Currently, there is no proper documentation on how to migrate from composite claims to namespaced XRs. As there is no crossplane tooling for the migration yet, we have developed a solution that works for us internally,

Below is a write-up of our approach that has been generalized a little bit that could serve as a starting point for documenting a possible migration.


Migration from crossplane v1 style Claims to namespaced composite resources (XRs)

Prerequisites for old composite claims

For the migration of composite claims we adapted the legacy style composition to do the following:

  • set deletion policy to "Orphan" for all managed resources in the old composition
  • add a label to all resources "resource/v1 = name" where name is the name of the "crossplane composite resource name" annotation
  • have a set of labels that uniquely identify the resources of a claim that are added to all resources, e.g.:
    • labels:
      • "example.com/composition=$composition"
      • "exmaple.com/id=$id"

New configuration for namespaced composite resources

As we need legacy resources and new resources to reside within the cluster at the same time for our migration approach to work, we have decided to create
new configurations for the namespaced composite resources. The new configurations have the following changes compared to the old compositions:

  • change group so it is different from the old composition. This was something we have opted to do as we wanted to change the group anyway because the old group name did not reflect our naming conventions. Therefore, we can avoid conflicts by using a different group name. Due to having the old and the new configuration inside our clusters at the same time, it might be a requirement to have different group names, however I am not 100% sure about that.
  • fresh start of apiVersion, i.e. v1alpha1 (optional, you can pick a version scheme that fits your needs)
  • change all resources from cluster scoped group version to namespaced group version, e.g.:
    • from: "clusters.rds.aws.upbound.io"
    • to: "clusters.rds.aws.m.upbound.io"
  • add a label to all resources "resource/v2 = name" where name is the name of the "crossplane composite resource name" annotation

DeletionPolicy/ManagementPolcies:

in the past we have used spec.deletionPolicy to control the lifecycle of managed resources. With the introduction of management policies, this has changed. Therefore, we need to adapt our compositions as follows:
As we make heavy use of KCL for writing our configurations, we have created a library that maps old deletionPolicy to new management policies. Additionally, we created a special entry Migration that is used for the migration process. This can vary depending on your needs, however we found that the following approach works well for us:

For all resources in the new composition function we do the following changes, when the deletionPolicy is set to Migration:

  • add additional annotation "crossplane.io/paused: true" to all resources in new composition. this way no new resources will be created on applying the new composition
    • set "pause" annotation on all newly created resources
    • overwrite spec.forProvider with the minimal required fields to identify this resource. Usually, that region
    • sets spec.managementPolicies to ["Observe", "LateInitialize"]

The above mentioned changes are required for the following reasons:

  • by pausing the resources, no new resources will be created in the new composition at the cloud provider level, but the managed resources will be created.
  • by setting the minimal required fields in spec.forProvider, we ensure that the managed resource can be observed and late initialized properly. This is required for the migration process to work, as we need to extract the external name of the old resource and set it to the new resource.
  • by setting the management policies to ["Observe", "LateInitialize"], we ensure that no unwanted changes are applied to the resources during the migration process.

Migration Process

  • install new configuration (with different name) alongside old configuration
  • for each instance of of claims in the old composition create a corresponding namespaced XR from the new configuration with the Migration deletionPolicy

Migration Script

We created a script that automates the migration process of resources from old composite claims to the new namespaced composite resources.
The script, generally speaking does the following:

  • use unique labels to find all resources of the old composition
  • use set of new unique identifier labels to find all resources of the new composition
  • for each resource in old composition:
    • map resource/v1 label to resource/v2 label
    • extract "crossplane.io/external-name" annotation and set it to the new resource
    • remove "crossplane.io/paused: true" annotation from new resource (will be reapplied, however it helps with seeing whether the migration was successful or not)

Make sure to adapt the script for your needs and test it thoroughly before using it in production environments. Additionally, it should reflect your own set of unique identifiers for finding the resources.

#!/usr/bin/env bash
# migrate-resources.sh

set -euo pipefail

log() {
  local ts
  ts=$(date "+%Y-%m-%d %H:%M:%S")
  echo "[${ts}] $*"
}

usage() {
  cat <<EOF
Usage:
  $(basename "$0") --namespace <ns> --composition <composition> --id <claimId> [--debug] [--verbose] [--remove-paused]
EOF
}

# Emit a single "-l <label1,label2,...>" argument string
build_selector_args() {
  local IFS=','
  # shellcheck disable=SC2048,SC2086
  echo -n "-l ${*}"
}

# -------------------- Parse args --------------------
namespace=""
composition=""
id=""
debug=0
verbose=0
remove_paused=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --namespace)
      namespace="$2"; shift 2 ;;
    --composition)
      composition="$2"; shift 2 ;;
    --id)
      id="$2"; shift 2 ;;
    --debug)
      debug=1; shift ;;
    --verbose)
      verbose=1; shift ;;
    --remove-paused)
      remove_paused=1; shift ;;
    -h|--help)
      usage; exit 0 ;;
    *)
      echo "ERROR: Unknown argument: $1" >&2
      usage
      exit 1 ;;
  esac
done

if [[ -z "$namespace" || -z "$composition" || -z "$id" ]]; then
  echo "ERROR: --namespace, --composition, and --id are required." >&2
  usage
  exit 1
fi

# -------------------- Configurable label arrays --------------------
# OLD (v1) are cluster-scoped → no -n
old_label_selectors=(
  "example.com/coposition=${composition}"
  "example.com/id=${id}"
)

# NEW (v2) are namespaced → use -n $namespace
new_label_selectors=(
  "new.example.com/coposition=${composition}"
  "new.example.com/id=${id}"
)

log "INFO: Starting migration compoisition='${composition}' id='${id}' (namespace='${namespace}')"
[[ $debug -eq 1 ]] && log "INFO: DEBUG mode enabled (dry-run) — no changes will be applied."
[[ $verbose -eq 1 ]] && log "INFO: VERBOSE mode enabled — kubectl commands will be printed."
[[ $remove_paused -eq 1 ]] && log "INFO: --remove-paused enabled — will remove annotation 'crossplane.io/paused' after successful patch."

# -------------------- Counters --------------------
 total=0
 patched=0
 missing=0
 failed=0
 skipped=0
 unpaused=0
 unpaused_failed=0

# -------------------- Fetch ALL old/new once --------------------
 t0=$(date +%s)

 old_selector_args=$(build_selector_args "${old_label_selectors[@]}")
 if [[ $verbose -eq 1 ]]; then
   log "CMD: kubectl get managed ${old_selector_args} -o json"
 fi
 if ! old_json=$(kubectl get managed ${old_selector_args} -o json 2>/dev/null); then
   log "ERROR: kubectl get for old MRs failed."; exit 1; fi
 if [[ -z "${old_json}" ]]; then
   log "ERROR: kubectl returned empty output for old MRs."; exit 1; fi

 new_selector_args=$(build_selector_args "${new_label_selectors[@]}")
 if [[ $verbose -eq 1 ]]; then
   log "CMD: kubectl get managed -n ${namespace} ${new_selector_args} -o json"
 fi
 if ! new_json=$(kubectl get managed -n "${namespace}" ${new_selector_args} -o json 2>/dev/null); then
   log "ERROR: kubectl get for new MRs failed."; exit 1; fi
 if [[ -z "${new_json}" ]]; then
   log "ERROR: kubectl returned empty output for new MRs."; exit 1; fi

 t1=$(date +%s)
 log "INFO: Prefetch completed in $(( t1 - t0 ))s"

# -------------------- Build in-memory indexes --------------------
# old_index: { "<resource/v1>": {name, external} }
 old_idx_json=$(jq -c '
  (.items // [])
  | map(select(.metadata.labels["resource/v1"] != null)
        | select(.metadata.annotations["crossplane.io/external-name"] != null)
        | {key: .metadata.labels["resource/v1"],
           value: {name: .metadata.name,
                   external: .metadata.annotations["crossplane.io/external-name"]}})
  | (reduce .[] as $i ({}; .[$i.key] = $i.value))
' <<<"${old_json}")

# new_index: { "<resource/v2>": {name, apiVersion, kind, annotations} }
 new_index=$(jq -c '
  .items as $it
  | {
      idx: (
        ($it // [])
        | map(select(.metadata.labels["resource/v2"] != null)
              | {key: .metadata.labels["resource/v2"],
                 value: {name: .metadata.name,
                         apiVersion: .apiVersion,
                         kind: .kind,
                         annotations: (.metadata.annotations // {})}})
        | (reduce .[] as $i ({}; .[$i.key] = (.[$i.key] // $i.value)))
      ),
      dups: (
        ($it // [])
        | map(.metadata.labels["resource/v2"])
        | group_by(.) | map(select(length>1) | {k: .[0], n: length})
      )
    }
' <<<"${new_json}")

 dup_count=$(jq '(.dups // []) | length' <<<"${new_index}")
 if [[ "${dup_count}" -gt 0 ]]; then
   # Print warnings but continue.
   while IFS= read -r line; do
     log "WARN: Multiple NEW MRs share resource/v2='${line}'"
   done < <(jq -r '.dups[] | "\(.k): \(.n)"' <<<"${new_index}")
 fi

 new_idx_json=$(jq -c '.idx' <<<"${new_index}")

 # Determine keys to process (from old index)
 key_count=$(jq 'keys | length' <<<"${old_idx_json}")
 log "INFO: Old MR keys discovered: ${key_count}"
 if [[ "${key_count}" -eq 0 ]]; then
   log "INFO: No old resources with both 'resource/v1' and 'external-name' found for selectors: ${old_label_selectors[*]}"
   echo "---------------------------------------------------"
   echo "Summary:"
   echo "  Total old MRs processed: 0"
   echo "  Successfully patched:    0"
   echo "  Missing matches:         0"
   echo "  Skipped:                 0"
   echo "  Failed:                  0"
   echo "---------------------------------------------------"
   exit 0
 fi

# -------------------- Process mapping in-memory --------------------
missing_keys=()
# Read keys safely (newline-delimited)
while IFS= read -r resource_key; do
  total=$(( total + 1 ))

  old_name=$(jq -r --arg k "$resource_key" '.[$k].name // empty' <<<"${old_idx_json}")
  external_name=$(jq -r --arg k "$resource_key" '.[$k].external // empty' <<<"${old_idx_json}")

  if [[ -z "$external_name" || -z "$old_name" ]]; then
    log "WARN: Skipping key '$resource_key' — incomplete old index entry."
    skipped=$(( skipped + 1 ))
    continue
  fi
  log "INFO: Old MR '${old_name}' resource/v1='${resource_key}' external-name='${external_name}'"

  new_name=$(jq -r --arg k "$resource_key" '.[$k].name // empty' <<<"${new_idx_json}")
  new_apiVersion=$(jq -r --arg k "$resource_key" '.[$k].apiVersion // empty' <<<"${new_idx_json}")
  new_kind=$(jq -r --arg k "$resource_key" '.[$k].kind // empty' <<<"${new_idx_json}")

  if [[ -z "$new_name" ]]; then
    log "WARN: No NEW MR found for resource/v2='${resource_key}' in namespace '${namespace}'."
    missing=$(( missing + 1 ))
    missing_keys+=("$resource_key")
    continue
  fi

  log "INFO: New MR '${new_name}' (${new_apiVersion} ${new_kind}) matches resource/v2='${resource_key}'"

  if [[ $debug -eq 1 ]]; then
    log "DEBUG: Would set annotation crossplane.io/external-name='${external_name}' on '${new_name}'"
    if [[ $remove_paused -eq 1 ]]; then
      log "DEBUG: Would remove annotation crossplane.io/paused from '${new_name}'"
    fi
    continue
  fi

  # Patch NEW MR (merge patch for external-name)
  patch_body=$(jq -c -n --arg v "$external_name" '{metadata:{annotations:{"crossplane.io/external-name":$v}}}')
  target_obj=$(jq -c -n --arg api "$new_apiVersion" --arg k "$new_kind" --arg n "$new_name" --arg ns "$namespace" '{apiVersion:$api,kind:$k,metadata:{name:$n,namespace:$ns}}')

  if [[ $verbose -eq 1 ]]; then
    log "CMD: kubectl patch --type merge -p '<patch>' -f <object>"
  fi

  patch_file=$(mktemp)
  target_file=$(mktemp)
  printf '%s' "$patch_body" >"$patch_file"
  printf '%s' "$target_obj" >"$target_file"

  if kubectl patch --type merge -p "$(cat "$patch_file")" -f "$target_file" 1>/dev/null 2>&1; then
    log "INFO: Patched '${new_name}' with external-name."
    patched=$(( patched + 1 ))

    if [[ $remove_paused -eq 1 ]]; then
      json_patch='[{"op":"remove","path":"/metadata/annotations/crossplane.io~1paused"}]'
      if [[ $verbose -eq 1 ]]; then
        log "CMD: kubectl patch --type json -p '<json-patch>' -f <object>"
      fi
      if kubectl patch --type json -p "${json_patch}" -f "$target_file" 1>/dev/null 2>&1; then
        log "INFO: Removed annotation crossplane.io/paused from '${new_name}'."
        unpaused=$(( unpaused + 1 ))
      else
        log "WARN: Could not remove crossplane.io/paused on '${new_name}' (not present or patch failed)."
        unpaused_failed=$(( unpaused_failed + 1 ))
      fi
    fi
  else
    log "ERROR: Patch failed for '${new_name}'."
    failed=$(( failed + 1 ))
  fi

  # Clean up temp files
  rm -f "$patch_file" "$target_file"

done < <(jq -r 'keys[]' <<<"${old_idx_json}")

# -------------------- Summary --------------------
 t2=$(date +%s)
 log "INFO: Migration completed in $(( t2 - t0 ))s total."
 echo "---------------------------------------------------"
 echo "Summary:"
 echo "  Total old MRs processed: ${total}"
 echo "  Successfully patched:    ${patched}"
 echo "  Missing matches:         ${missing}"
 echo "  Skipped:                 ${skipped}"
 echo "  Failed:                  ${failed}"
 if [[ $remove_paused -eq 1 ]]; then
   echo "  Unpaused (removed paused):     ${unpaused}"
   echo "  Unpause failed/skipped:        ${unpaused_failed}"
 fi
 echo "Selectors used:"
 echo "  OLD (cluster-scoped): ${old_label_selectors[*]}"
 echo "  NEW (namespaced):     ${new_label_selectors[*]} + resource/v2=<resource>"
 echo "Namespace (new only):   ${namespace}"
 echo "Flags:"
 echo "  debug=${debug} verbose=${verbose} remove_paused=${remove_paused}"
 echo "---------------------------------------------------"

 if [[ ${missing} -gt 0 ]]; then
   echo ""
   echo "DEBUG: Missing matches summary:"
   for key in "${missing_keys[@]}"; do
     echo "  - resource/v2='${key}' (no corresponding new MR found)"
   done
 fi

Post initial migration

  • after migration of resources, remove "crossplane.io/paused: true" annotation from all resources in new composition (by changing the value in the composition function)
  • delete old claims and old compositions

Rollout

As we are using a combination of ArgoCD and Helm for rolling out our claims (and now namespaced resources) the following rollout approach worked well for us:

  • create a new Helm chart version that deploys the old claim and the new namespaced composite resource (with Migration deletion policy)
  • add a post sync hook that runs the migration script after the new Helm chart version is deployed (make sure that the script has access to the kubeconfig of the cluster)

  • verify that migration has been successful
  • deploy new version of the chart that no longer has the post sync hook job, removes old composite claim and changes deletionPolicy/managementPolicies in new composition to desired state

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions