Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions migration/examples/cmd/migrate-operators-v0-to-v1/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ func runConvert(cmd *cobra.Command, args []string) error { //nolint:nestif
bundleInfo.ResolvedCatalogName = catalogName
success(fmt.Sprintf("Selected ClusterCatalog: %s", catalogName))

// Verify every OLMv1 prerequisite before deleting the Subscription or CSV.
// This also discovers the operator-controller namespace used by SecretPacker.
opts, err = m.PrepareClusterObjectSet(ctx, opts)
if err != nil {
return fmt.Errorf("ClusterObjectSet prerequisite check failed: %w", err)
}
success(fmt.Sprintf("ClusterObjectSet API established; using operator-controller namespace %s", opts.SystemNamespace))

stepHeader(4, "Collecting operator resources")
objects, err := m.CollectResources(ctx, opts, csv, ip, bundleInfo.PackageName)
if err != nil {
Expand Down Expand Up @@ -256,26 +264,18 @@ func runConvert(cmd *cobra.Command, args []string) error { //nolint:nestif
}
success("OLMv0 management removed")

stepHeader(7, "Creating ClusterObjectSet")
info(fmt.Sprintf("Applying COS %s-1 with %d objects...", opts.ClusterExtensionName, len(bundleInfo.CollectedObjects)))
stepHeader(7, "Creating OLMv1 migration resources")
info(fmt.Sprintf("Applying COS %s-1 with %d objects and creating its ClusterExtension...", opts.ClusterExtensionName, len(bundleInfo.CollectedObjects)))
startProgress()
if err := m.CreateClusterObjectSet(ctx, opts, bundleInfo); err != nil {
if err := m.CreateMigrationResources(ctx, opts, bundleInfo, backup); err != nil {
clearProgress()
return fmt.Errorf("COS creation failed: %w", err)
return err
}
clearProgress()
success(fmt.Sprintf("ClusterObjectSet %s-1 reached Succeeded=True", opts.ClusterExtensionName))

stepHeader(8, "Creating ClusterExtension")
startProgress()
if err := m.CreateClusterExtension(ctx, opts, bundleInfo); err != nil {
clearProgress()
return fmt.Errorf("failed to create ClusterExtension: %w", err)
}
clearProgress()
success(fmt.Sprintf("ClusterExtension %s is Installed", opts.ClusterExtensionName))

stepHeader(9, "Cleaning up OLMv0 resources")
stepHeader(8, "Cleaning up OLMv0 resources")
cleanupResult := m.CleanupOLMv0Resources(ctx, opts, bundleInfo.PackageName, csv.Name)
for _, action := range cleanupResult.Actions {
switch {
Expand All @@ -297,12 +297,23 @@ func runConvertDryRun(cmd *cobra.Command, m *migration.Migrator, opts migration.
ctx := cmd.Context()
fmt.Printf("\n%s%s🔍 Dry run: %s/%s%s\n", colorBold, colorCyan, opts.SubscriptionNamespace, opts.SubscriptionName, colorReset)

// Dry-run must reject a target that cannot create a COS, just as a real
// conversion would. This is read-only and runs before gathering the preview.
var err error
opts, err = m.PrepareClusterObjectSet(ctx, opts)
if err != nil {
return fmt.Errorf("ClusterObjectSet prerequisite check failed: %w", err)
}
success(fmt.Sprintf("ClusterObjectSet API established; using operator-controller namespace %s", opts.SystemNamespace))

info, err := m.GatherMigrationInfo(ctx, opts)
if err != nil {
return fmt.Errorf("failed to gather migration info: %w", err)
}

success(fmt.Sprintf("Package: %s Version: %s Channel: %s", info.PackageName, info.Version, valueOrDefault(info.Channel, "(default)")))
fmt.Printf("\n Resources that would be created:\n")
detail("ClusterObjectSet:", fmt.Sprintf("%s-1 (wait for Succeeded=True before creating the ClusterExtension)", opts.ClusterExtensionName))
fmt.Printf("\n Resources that would be placed into ClusterObjectSet %s-1:\n", opts.ClusterExtensionName)

kindCounts := make(map[string]int)
Expand All @@ -325,11 +336,41 @@ func runConvertDryRun(cmd *cobra.Command, m *migration.Migrator, opts migration.
detail("Channel:", valueOrDefault(info.Channel, "(none set)"))
detail("CollisionProtection:", "IfNoController")

fmt.Printf("\n OLMv0 resources that would be deleted or changed:\n")
for _, line := range dryRunCleanupPlan(opts, info) {
info2(line)
}

fmt.Printf("\n Backup plan:\n")
info2("Store Subscription and OperatorGroup specifications in ClusterExtension annotations before deletion.")
if opts.BackupDirectory != "" {
info2(fmt.Sprintf("Write Subscription, OperatorGroup, CSV, and InstallPlan YAML to %s before deletion (not written during dry run).", opts.BackupDirectory))
}

fmt.Println()
info2("No cluster resources were modified (dry run).")
return nil
}

// dryRunCleanupPlan describes all OLMv0 cleanup actions performed by a normal
// conversion. It intentionally calls no API: dry-run must remain non-mutating.
func dryRunCleanupPlan(opts migration.Options, info *migration.MigrationInfo) []string {
lines := []string{
fmt.Sprintf("Delete Subscription %s/%s with orphan propagation (operator workloads remain).", opts.SubscriptionNamespace, opts.SubscriptionName),
fmt.Sprintf("Delete ClusterServiceVersion %s/%s with orphan propagation (operator workloads remain).", opts.SubscriptionNamespace, info.BundleName),
fmt.Sprintf("Delete Operator CR %s.%s.", info.PackageName, opts.SubscriptionNamespace),
fmt.Sprintf("Delete OperatorCondition %s/%s if present.", opts.SubscriptionNamespace, info.BundleName),
fmt.Sprintf("Delete copied ClusterServiceVersions derived from %s if present, with orphan propagation.", info.BundleName),
"Retain InstallPlan resources; conversion does not delete them.",
}
if opts.DeleteOperatorGroup {
lines = append(lines, "Delete OperatorGroup(s) only when no Subscriptions remain; strip OLM ownership labels from their aggregation ClusterRoles first.")
} else {
lines = append(lines, "Retain OperatorGroup(s); --delete-operatorgroup was not specified.")
}
return lines
}

func info2(msg string) {
fmt.Printf(" %s\n", msg)
}
Expand Down
41 changes: 41 additions & 0 deletions migration/examples/cmd/migrate-operators-v0-to-v1/convert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package main

import (
"strings"
"testing"

"github.com/operator-framework/library-olm/migration/pkg/migration"
)

func TestDryRunCleanupPlan(t *testing.T) {
opts := migration.Options{
SubscriptionName: "widget-operator",
SubscriptionNamespace: "operators",
DeleteOperatorGroup: true,
}
info := &migration.MigrationInfo{
PackageName: "widgets",
BundleName: "widgets.v1.2.3",
}

plan := strings.Join(dryRunCleanupPlan(opts, info), "\n")
for _, expected := range []string{
"Delete Subscription operators/widget-operator with orphan propagation",
"Delete ClusterServiceVersion operators/widgets.v1.2.3 with orphan propagation",
"Delete Operator CR widgets.operators",
"Delete OperatorCondition operators/widgets.v1.2.3 if present",
"Delete copied ClusterServiceVersions derived from widgets.v1.2.3 if present",
"Retain InstallPlan resources",
"Delete OperatorGroup(s) only when no Subscriptions remain",
} {
if !strings.Contains(plan, expected) {
t.Errorf("dry-run cleanup plan does not include %q:\n%s", expected, plan)
}
}

opts.DeleteOperatorGroup = false
plan = strings.Join(dryRunCleanupPlan(opts, info), "\n")
if !strings.Contains(plan, "Retain OperatorGroup(s); --delete-operatorgroup was not specified.") {
t.Fatalf("dry-run cleanup plan does not describe the default OperatorGroup behavior:\n%s", plan)
}
}
71 changes: 59 additions & 12 deletions migration/pkg/migration/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"time"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/kubernetes"
Expand Down Expand Up @@ -115,22 +116,26 @@ func catalogEndpoint(ctx context.Context, catalog *ocv1.ClusterCatalog, config *
if err != nil {
return "", nil, nil, fmt.Errorf("create Kubernetes client for catalog port-forward: %w", err)
}
catalogConfig, err := catalogdTLSConfig(ctx, clientset, config)
namespace, serverName, err := catalogdServiceLocation(catalog)
if err != nil {
return "", nil, nil, err
}
if inCluster {
return catalog.Status.URLs.Base + "/api/v1/all", func() {}, catalogConfig, nil
podName, err := catalogdLeader(ctx, clientset, namespace)
if err != nil {
return "", nil, nil, err
}
podName, err := catalogdLeader(ctx, clientset)
catalogConfig, err := catalogdTLSConfig(ctx, clientset, config, namespace, podName)
if err != nil {
return "", nil, nil, err
}
if inCluster {
return catalog.Status.URLs.Base + "/api/v1/all", func() {}, catalogConfig, nil
}
u, err := url.Parse(config.Host)
if err != nil {
return "", nil, nil, err
}
u.Path = path.Join(u.Path, "api", "v1", "namespaces", "olmv1-system", "pods", podName, "portforward")
u.Path = path.Join(u.Path, "api", "v1", "namespaces", namespace, "pods", podName, "portforward")
rt, upgrader, err := spdy.RoundTripperFor(config)
if err != nil {
return "", nil, nil, fmt.Errorf("create catalogd port-forward: %w", err)
Expand Down Expand Up @@ -161,13 +166,46 @@ func catalogEndpoint(ctx context.Context, catalog *ocv1.ClusterCatalog, config *
close(stop)
return "", nil, nil, err
}
catalogConfig.ServerName = "localhost"
// The local port-forward address is not the catalogd certificate's identity.
// Verify the service DNS name advertised by ClusterCatalog status instead.
catalogConfig.ServerName = serverName
return fmt.Sprintf("https://127.0.0.1:%d/catalogs/%s/api/v1/all", ports[0].Local, catalog.Name), func() { close(stop) }, catalogConfig, nil
}

// catalogdTLSConfig replaces the Kubernetes API CA with catalogd's serving CA.
func catalogdTLSConfig(ctx context.Context, clientset kubernetes.Interface, config *rest.Config) (*rest.Config, error) {
secret, err := clientset.CoreV1().Secrets("cert-manager").Get(ctx, "olmv1-ca", metav1.GetOptions{})
// catalogdServiceLocation derives catalogd's service namespace and TLS server
// name from the in-cluster endpoint published by operator-controller. This
// avoids imposing either the upstream cert-manager layout or OpenShift's
// service-ca layout on migration users.
func catalogdServiceLocation(catalog *ocv1.ClusterCatalog) (string, string, error) {
if catalog.Status.URLs == nil || catalog.Status.URLs.Base == "" {
return "", "", fmt.Errorf("catalog %s has no base URL in status", catalog.Name)
}
u, err := url.Parse(catalog.Status.URLs.Base)
if err != nil {
return "", "", fmt.Errorf("parse catalog %s base URL: %w", catalog.Name, err)
}
host := u.Hostname()
parts := strings.Split(host, ".")
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] != "svc" {
return "", "", fmt.Errorf("catalog %s base URL %q does not use a Kubernetes service hostname", catalog.Name, catalog.Status.URLs.Base)
}
return parts[1], host, nil
}

// catalogdTLSConfig replaces the Kubernetes API CA with the CA carried by the
// serving certificate mounted in the current catalogd leader Pod. The secret
// name is deliberately discovered from the Pod: upstream installs use a
// cert-manager secret while OpenShift uses a service-ca-generated secret.
func catalogdTLSConfig(ctx context.Context, clientset kubernetes.Interface, config *rest.Config, namespace, podName string) (*rest.Config, error) {
pod, err := clientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("get catalogd pod: %w", err)
}
secretName := catalogdServingCertificateSecret(pod)
if secretName == "" {
return nil, fmt.Errorf("catalogd pod %s/%s has no serving certificate Secret", namespace, podName)
}
secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("get catalogd CA: %w", err)
}
Expand All @@ -190,17 +228,26 @@ func catalogdTLSConfig(ctx context.Context, clientset kubernetes.Interface, conf
return catalogConfig, nil
}

func catalogdServingCertificateSecret(pod *corev1.Pod) string {
for _, volume := range pod.Spec.Volumes {
if volume.Name == "catalogserver-certs" && volume.Secret != nil {
return volume.Secret.SecretName
}
}
return ""
}

// catalogdLeader waits for catalogd's leader Lease to reference a current pod.
func catalogdLeader(ctx context.Context, clientset kubernetes.Interface) (string, error) {
func catalogdLeader(ctx context.Context, clientset kubernetes.Interface, namespace string) (string, error) {
var lastErr error
var leader string
err := wait.PollUntilContextTimeout(ctx, time.Second, 30*time.Second, true, func(context.Context) (bool, error) {
pods, err := clientset.CoreV1().Pods("olmv1-system").List(ctx, metav1.ListOptions{LabelSelector: "app.kubernetes.io/name=catalogd"})
pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: "app.kubernetes.io/name=catalogd"})
if err != nil {
lastErr = fmt.Errorf("list catalogd pods: %w", err)
return false, nil
}
lease, err := clientset.CoordinationV1().Leases("olmv1-system").Get(ctx, "catalogd-operator-lock", metav1.GetOptions{})
lease, err := clientset.CoordinationV1().Leases(namespace).Get(ctx, "catalogd-operator-lock", metav1.GetOptions{})
if err != nil {
lastErr = fmt.Errorf("get catalogd leader lease: %w", err)
return false, nil
Expand Down
Loading
Loading