1- import { ApiException } from '@kubernetes/client-node'
1+ import { ApiException , PatchStrategy , setHeaderOptions } from '@kubernetes/client-node'
22import { encryptSecretItem } from '@linode/kubeseal-encrypt'
33import { randomUUID } from 'crypto'
44import { diff } from 'deep-diff'
@@ -19,9 +19,9 @@ import { BasicArguments, getParsedArgs, setParsedArgs } from 'src/common/yargs'
1919import { v4 as uuidv4 } from 'uuid'
2020import { parse } from 'yaml'
2121import { Argv } from 'yargs'
22- import { $ , cd } from 'zx'
22+ import { $ , cd , sleep } from 'zx'
2323import { APL_OPERATOR_NS , ARGOCD_APP_PARAMS } from '../common/constants'
24- import { getK8sSecret , getSealedSecretsPEM , k8s } from '../common/k8s'
24+ import { getArgoCdApp , getK8sSecret , getSealedSecretsPEM , k8s , setArgoCdAppSync } from '../common/k8s'
2525
2626const cmdName = getFilename ( __filename )
2727
@@ -520,6 +520,107 @@ async function appExists(name: string): Promise<boolean> {
520520 )
521521}
522522
523+ async function hasPvcWithStorageClass (
524+ namespace : string ,
525+ labelSelector : string ,
526+ storageClassName : string ,
527+ ) : Promise < boolean > {
528+ try {
529+ const pvcList = await k8s . core ( ) . listNamespacedPersistentVolumeClaim ( { namespace, labelSelector } )
530+ return ( pvcList ?. items || [ ] ) . some ( ( pvc ) => pvc ?. spec ?. storageClassName === storageClassName )
531+ } catch ( error ) {
532+ if ( error instanceof ApiException && error . code === 404 ) {
533+ return false
534+ }
535+ throw error
536+ }
537+ }
538+
539+ async function waitForPodsDeletion ( namespace : string , labelSelector : string , timeoutMs = 300000 ) : Promise < void > {
540+ const start = Date . now ( )
541+ while ( Date . now ( ) - start < timeoutMs ) {
542+ const pods = await k8s . core ( ) . listNamespacedPod ( { namespace, labelSelector } )
543+ if ( ( pods . items || [ ] ) . length === 0 ) return
544+ await sleep ( 5000 )
545+ }
546+ }
547+
548+ async function waitForStatefulSetDeletion ( name : string , namespace : string , timeoutMs = 300000 ) : Promise < void > {
549+ const start = Date . now ( )
550+ while ( Date . now ( ) - start < timeoutMs ) {
551+ const exists = await checkExists ( async ( ) => await k8s . app ( ) . readNamespacedStatefulSet ( { name, namespace } ) )
552+ if ( ! exists ) return
553+ await sleep ( 5000 )
554+ }
555+ }
556+
557+ async function deletePvcsByLabel ( namespace : string , labelSelector : string ) : Promise < void > {
558+ const pvcList = await k8s . core ( ) . listNamespacedPersistentVolumeClaim ( { namespace, labelSelector } )
559+ for ( const pvc of pvcList . items || [ ] ) {
560+ const name = pvc ?. metadata ?. name
561+ if ( ! name ) continue
562+ try {
563+ await k8s . core ( ) . deleteNamespacedPersistentVolumeClaim ( { name, namespace } )
564+ } catch ( error ) {
565+ if ( ! ( error instanceof ApiException && error . code === 404 ) ) throw error
566+ }
567+ }
568+ }
569+
570+ async function migrateStatefulSetPvc ( opts : {
571+ appName : string
572+ statefulSetName : string
573+ namespace : string
574+ pvcLabelSelector : string
575+ d : ReturnType < typeof terminal >
576+ } ) : Promise < void > {
577+ const parsedArgs = getParsedArgs ( )
578+ if ( parsedArgs ?. dryRun || parsedArgs ?. local ) {
579+ return
580+ }
581+
582+ let syncDisabled = false
583+ try {
584+ const app = await getArgoCdApp ( opts . appName , k8s . custom ( ) )
585+ if ( app ) {
586+ await setArgoCdAppSync ( opts . appName , false , k8s . custom ( ) )
587+ syncDisabled = true
588+ } else {
589+ opts . d . info ( `Argo CD application ${ opts . appName } not found. Skipping sync disable.` )
590+ }
591+
592+ await k8s . app ( ) . patchNamespacedStatefulSet (
593+ {
594+ name : opts . statefulSetName ,
595+ namespace : opts . namespace ,
596+ body : { spec : { replicas : 0 } } ,
597+ } ,
598+ setHeaderOptions ( 'Content-Type' , PatchStrategy . StrategicMergePatch ) ,
599+ )
600+
601+ await waitForPodsDeletion ( opts . namespace , opts . pvcLabelSelector )
602+
603+ try {
604+ await k8s . app ( ) . deleteNamespacedStatefulSet ( { name : opts . statefulSetName , namespace : opts . namespace } )
605+ } catch ( error ) {
606+ if ( ! ( error instanceof ApiException && error . code === 404 ) ) throw error
607+ }
608+
609+ await waitForStatefulSetDeletion ( opts . statefulSetName , opts . namespace )
610+ await deletePvcsByLabel ( opts . namespace , opts . pvcLabelSelector )
611+ } catch ( error ) {
612+ throw error
613+ } finally {
614+ if ( syncDisabled ) {
615+ try {
616+ await setArgoCdAppSync ( opts . appName , true , k8s . custom ( ) )
617+ } catch ( error ) {
618+ opts . d . warn ( `Failed to re-enable Argo CD sync for ${ opts . appName } : ${ error } ` )
619+ }
620+ }
621+ }
622+ }
623+
523624export async function addAplOperator ( ) : Promise < void > {
524625 const d = terminal ( 'addAplOperator' )
525626 if ( await namespaceExists ( APL_OPERATOR_NS ) ) {
@@ -713,6 +814,51 @@ const createCatalogSealedSecret = async (
713814 writeFileSync ( sealedSecretPath , objectToYaml ( sealedSecret ) )
714815}
715816
817+ // This migration changes PVCs when using Linode for gitea-valkey and oauth2-proxy-redis-server to use linode-block-storage instead of linode-block-storage-retain
818+ const valkeyAndOauth2RedisPVCMigration = async ( values : Record < string , any > ) : Promise < void > => {
819+ const d = terminal ( 'valkeyAndOauth2RedisPVCMigration' )
820+ if ( env . DISABLE_SYNC ) {
821+ d . info ( 'Skipping Valkey and Oauth2 Redis PVC migration in dev/test environment' )
822+ return
823+ }
824+ const giteaEnabled = values ?. apps ?. gitea ?. enabled
825+ const isLinode = values ?. cluster ?. provider === 'linode'
826+ const legacyStorageClass = 'linode-block-storage-retain'
827+ if ( isLinode ) {
828+ d . info ( 'Changing PVC storage class to linode-block-storage for Gitea and OAuth2 Proxy Redis Server' )
829+ if ( giteaEnabled ) {
830+ const hasLegacyGiteaPvc = await hasPvcWithStorageClass (
831+ 'gitea' ,
832+ 'app.kubernetes.io/name=valkey' ,
833+ legacyStorageClass ,
834+ )
835+ if ( hasLegacyGiteaPvc ) {
836+ await migrateStatefulSetPvc ( {
837+ appName : 'gitea-gitea-valkey' ,
838+ statefulSetName : 'gitea-valkey-primary' ,
839+ namespace : 'gitea' ,
840+ pvcLabelSelector : 'app.kubernetes.io/name=valkey' ,
841+ d,
842+ } )
843+ } else {
844+ d . info ( `Skipping gitea PVC migration: no PVC found with storageClass ${ legacyStorageClass } ` )
845+ }
846+ }
847+ const hasLegacyOauthPvc = await hasPvcWithStorageClass ( 'istio-system' , 'app=redis' , legacyStorageClass )
848+ if ( hasLegacyOauthPvc ) {
849+ await migrateStatefulSetPvc ( {
850+ appName : 'istio-system-oauth2-proxy' ,
851+ statefulSetName : 'oauth2-proxy-redis-ha-server' ,
852+ namespace : 'istio-system' ,
853+ pvcLabelSelector : 'app=redis' ,
854+ d,
855+ } )
856+ } else {
857+ d . info ( `Skipping oauth2-proxy redis PVC migration: no PVC found with storageClass ${ legacyStorageClass } ` )
858+ }
859+ }
860+ }
861+
716862const setDefaultAplCatalog = async ( values : Record < string , any > ) : Promise < void > => {
717863 const d = terminal ( 'setDefaultAplCatalog' )
718864 const gitea = values ?. apps ?. gitea as { adminUsername ?: string ; adminPassword ?: string } | undefined
@@ -843,6 +989,7 @@ const customMigrationFunctions: Record<string, CustomMigrationFunction> = {
843989 workloadValuesMigration,
844990 setLokiStorageSchemaMigration,
845991 setDefaultAplCatalog,
992+ valkeyAndOauth2RedisPVCMigration,
846993 addLinodeNBAnnotations,
847994 setIngressDefault,
848995}
0 commit comments