diff --git a/packages/api-v4/.changeset/pr-11259-upcoming-features-1731690339160.md b/packages/api-v4/.changeset/pr-11259-upcoming-features-1731690339160.md new file mode 100644 index 00000000000..ef9c0826908 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11259-upcoming-features-1731690339160.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add v4beta/account endpoint and update Capabilities for LKE-E ([#11259](https://github.com/linode/manager/pull/11259)) diff --git a/packages/api-v4/src/account/account.ts b/packages/api-v4/src/account/account.ts index b612da3d082..0b704f230b6 100644 --- a/packages/api-v4/src/account/account.ts +++ b/packages/api-v4/src/account/account.ts @@ -35,6 +35,18 @@ export const getAccountInfo = () => { return Request(setURL(`${API_ROOT}/account`), setMethod('GET')); }; +/** + * getAccountInfoBeta + * + * Return beta endpoint account information, + * including contact and billing info. + * + * @TODO LKE-E - M3-8838: Clean up after released to GA, if not otherwise in use + */ +export const getAccountInfoBeta = () => { + return Request(setURL(`${BETA_API_ROOT}/account`), setMethod('GET')); +}; + /** * getNetworkUtilization * diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 865a9956dbc..6bd9ee37d3f 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -68,6 +68,7 @@ export type AccountCapability = | 'CloudPulse' | 'Disk Encryption' | 'Kubernetes' + | 'Kubernetes Enterprise' | 'Linodes' | 'LKE HA Control Planes' | 'LKE Network Access Control List (IP ACL)' diff --git a/packages/manager/.changeset/pr-11259-upcoming-features-1731690367042.md b/packages/manager/.changeset/pr-11259-upcoming-features-1731690367042.md new file mode 100644 index 00000000000..cfe1d49d994 --- /dev/null +++ b/packages/manager/.changeset/pr-11259-upcoming-features-1731690367042.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add feature flag and hook for LKE-E enablement ([#11259](https://github.com/linode/manager/pull/11259)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index c87b9a404f3..55698daeb63 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -27,6 +27,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, { flag: 'imageServiceGen2Ga', label: 'Image Service Gen2 GA' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, + { flag: 'lkeEnterprise', label: 'LKE-Enterprise' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index e2e160c535c..cc039ff04a1 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -64,6 +64,11 @@ interface AclpFlag { enabled: boolean; } +interface LkeEnterpriseFlag extends BaseFeatureFlag { + ga: boolean; + la: boolean; +} + export interface CloudPulseResourceTypeMapFlag { dimensionKey: string; maxResourceSelections?: number; @@ -109,6 +114,7 @@ export interface Flags { imageServiceGen2Ga: boolean; ipv6Sharing: boolean; linodeDiskEncryption: boolean; + lkeEnterprise: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; metadata: boolean; diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 5e523f64543..93ad77b2501 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { accountBetaFactory, @@ -6,16 +6,49 @@ import { linodeTypeFactory, nodePoolFactory, } from 'src/factories'; -import { HttpResponse, http, server } from 'src/mocks/testServer'; import { extendType } from 'src/utilities/extendType'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; import { getLatestVersion, getTotalClusterMemoryCPUAndStorage, useAPLAvailability, + useIsLkeEnterpriseEnabled, } from './kubeUtils'; +const queryMocks = vi.hoisted(() => ({ + useAccountBeta: vi.fn().mockReturnValue({}), + useAccountBetaQuery: vi.fn().mockReturnValue({}), + useFlags: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/account/account', () => { + const actual = vi.importActual('src/queries/account/account'); + return { + ...actual, + useAccountBeta: queryMocks.useAccountBeta, + }; +}); + +vi.mock('src/queries/account/betas', () => { + const actual = vi.importActual('src/queries/account/betas'); + return { + ...actual, + useAccountBetaQuery: queryMocks.useAccountBetaQuery, + }; +}); + +vi.mock('src/hooks/useFlags', () => { + const actual = vi.importActual('src/hooks/useFlags'); + return { + ...actual, + useFlags: queryMocks.useFlags, + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + describe('helper functions', () => { const badPool = nodePoolFactory.build({ type: 'not-a-real-type', @@ -73,25 +106,26 @@ describe('helper functions', () => { }); }); }); + describe('APL availability', () => { it('should return true if the apl flag is true and beta is active', async () => { const accountBeta = accountBetaFactory.build({ enrolled: '2023-01-15T00:00:00Z', id: 'apl', }); - server.use( - http.get('*/account/betas/apl', () => { - return HttpResponse.json(accountBeta); - }) - ); - const { result } = renderHook(() => useAPLAvailability(), { - wrapper: (ui) => wrapWithTheme(ui, { flags: { apl: true } }), + + queryMocks.useAccountBetaQuery.mockReturnValue({ + data: accountBeta, }); - await waitFor(() => { - expect(result.current.showAPL).toBe(true); + queryMocks.useFlags.mockReturnValue({ + apl: true, }); + + const { result } = renderHook(() => useAPLAvailability()); + expect(result.current.showAPL).toBe(true); }); }); + describe('getLatestVersion', () => { it('should return the correct latest version from a list of versions', () => { const versions = [ @@ -128,3 +162,68 @@ describe('helper functions', () => { }); }); }); + +describe('useIsLkeEnterpriseEnabled', () => { + it('returns false if the account does not have the capability', () => { + queryMocks.useAccountBeta.mockReturnValue({ + data: { + capabilities: [], + }, + }); + queryMocks.useFlags.mockReturnValue({ + lkeEnterprise: { + enabled: true, + ga: true, + la: true, + }, + }); + + const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + expect(result.current).toStrictEqual({ + isLkeEnterpriseGAEnabled: false, + isLkeEnterpriseLAEnabled: false, + }); + }); + + it('returns true for LA if the account has the capability + enabled LA feature flag values', () => { + queryMocks.useAccountBeta.mockReturnValue({ + data: { + capabilities: ['Kubernetes Enterprise'], + }, + }); + queryMocks.useFlags.mockReturnValue({ + lkeEnterprise: { + enabled: true, + ga: false, + la: true, + }, + }); + + const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + expect(result.current).toStrictEqual({ + isLkeEnterpriseGAEnabled: false, + isLkeEnterpriseLAEnabled: true, + }); + }); + + it('returns true for GA if the account has the capability + enabled GA feature flag values', () => { + queryMocks.useAccountBeta.mockReturnValue({ + data: { + capabilities: ['Kubernetes Enterprise'], + }, + }); + queryMocks.useFlags.mockReturnValue({ + lkeEnterprise: { + enabled: true, + ga: true, + la: true, + }, + }); + + const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + expect(result.current).toStrictEqual({ + isLkeEnterpriseGAEnabled: true, + isLkeEnterpriseLAEnabled: true, + }); + }); +}); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index 289b4f302d6..b90963aa481 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,4 +1,5 @@ import { useFlags } from 'src/hooks/useFlags'; +import { useAccountBeta } from 'src/queries/account/account'; import { useAccountBetaQuery } from 'src/queries/account/betas'; import { getBetaStatus } from 'src/utilities/betaUtils'; import { sortByVersion } from 'src/utilities/sort-by'; @@ -11,6 +12,7 @@ import type { } from '@linode/api-v4/lib/kubernetes'; import type { Region } from '@linode/api-v4/lib/regions'; import type { ExtendedType } from 'src/utilities/extendType'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; export const nodeWarning = `We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.`; export const nodesDeletionWarning = `All nodes will be deleted and new nodes will be created to replace them.`; export const localStorageWarning = `Any local storage (such as \u{2019}hostPath\u{2019} volumes) will be erased.`; @@ -184,3 +186,34 @@ export const getLatestVersion = ( return { label: `${latestVersion.value}`, value: `${latestVersion.value}` }; }; + +/** + * Hook to determine if the LKE-Enterprise feature should be visible to the user. + * Based on the user's account capability and the feature flag. + * + * @returns {boolean, boolean} - Whether the LKE-Enterprise feature is enabled for the current user in LA and GA, respectively. + */ +export const useIsLkeEnterpriseEnabled = () => { + const flags = useFlags(); + const { data: account } = useAccountBeta(); + + const isLkeEnterpriseLA = Boolean( + flags?.lkeEnterprise?.enabled && flags.lkeEnterprise.la + ); + const isLkeEnterpriseGA = Boolean( + flags.lkeEnterprise?.enabled && flags.lkeEnterprise.ga + ); + + const isLkeEnterpriseLAEnabled = isFeatureEnabledV2( + 'Kubernetes Enterprise', + isLkeEnterpriseLA, + account?.capabilities ?? [] + ); + const isLkeEnterpriseGAEnabled = isFeatureEnabledV2( + 'Kubernetes Enterprise', + isLkeEnterpriseGA, + account?.capabilities ?? [] + ); + + return { isLkeEnterpriseLAEnabled, isLkeEnterpriseGAEnabled }; +}; diff --git a/packages/manager/src/mocks/presets/extra/account/lkeEnterpriseEnabled.ts b/packages/manager/src/mocks/presets/extra/account/lkeEnterpriseEnabled.ts new file mode 100644 index 00000000000..721b71cf4cc --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/lkeEnterpriseEnabled.ts @@ -0,0 +1,33 @@ +// New file at `src/mocks/presets/extra/account/lkeEnterpriseEnabled.ts` or similar + +import { http } from 'msw'; + +import { accountFactory } from 'src/factories'; +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +const mockLkeEnabledCapability = () => { + return [ + http.get('*/v4*/account', async ({ request }) => { + return makeResponse( + accountFactory.build({ + capabilities: [ + // Other account capabilities might be necessary here, too... + // TODO Make a `defaultAccountCapabilities` factory. + 'Kubernetes', + 'Kubernetes Enterprise', + ], + }) + ); + }), + ]; +}; + +export const lkeEnterpriseEnabledPreset: MockPresetExtra = { + desc: 'Mock account with LKE Enterprise capability', + group: { id: 'Account', type: 'select' }, + handlers: [mockLkeEnabledCapability], + id: 'account:lke-enterprise-enabled', + label: 'LKE Enterprise Enabled', +}; diff --git a/packages/manager/src/mocks/presets/index.ts b/packages/manager/src/mocks/presets/index.ts index 1d40d1fe095..762868ded83 100644 --- a/packages/manager/src/mocks/presets/index.ts +++ b/packages/manager/src/mocks/presets/index.ts @@ -6,6 +6,7 @@ import { baselineCrudPreset } from './baseline/crud'; import { baselineLegacyPreset } from './baseline/legacy'; import { baselineNoMocksPreset } from './baseline/noMocks'; import { childAccountPreset } from './extra/account/childAccount'; +import { lkeEnterpriseEnabledPreset } from './extra/account/lkeEnterpriseEnabled'; import { managedDisabledPreset } from './extra/account/managedDisabled'; import { managedEnabledPreset } from './extra/account/managedEnabled'; import { parentAccountPreset } from './extra/account/parentAccount'; @@ -43,6 +44,7 @@ export const extraMockPresets: MockPresetExtra[] = [ childAccountPreset, linodeLimitsPreset, lkeLimitsPreset, + lkeEnterpriseEnabledPreset, managedEnabledPreset, managedDisabledPreset, coreAndDistributedRegionsPreset, diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 8ef5563dc4b..2efbd4982be 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -52,6 +52,7 @@ export type MockPresetExtraGroup = { }; export type MockPresetExtraId = | 'account:child-proxy' + | 'account:lke-enterprise-enabled' | 'account:managed-disabled' | 'account:managed-enabled' | 'account:parent' diff --git a/packages/manager/src/queries/account/account.ts b/packages/manager/src/queries/account/account.ts index 2d9c787c57e..6511deba3db 100644 --- a/packages/manager/src/queries/account/account.ts +++ b/packages/manager/src/queries/account/account.ts @@ -11,6 +11,7 @@ import { import { useSnackbar } from 'notistack'; import { useIsTaxIdEnabled } from 'src/features/Account/utils'; +import { useFlags } from 'src/hooks/useFlags'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; @@ -36,6 +37,21 @@ export const useAccount = () => { }); }; +/** + * @TODO LKE-E - M3-8838: Clean up after released to GA, if not otherwise in use + */ +export const useAccountBeta = () => { + const { data: profile } = useProfile(); + const flags = useFlags(); + + return useQuery({ + ...accountQueries.accountBeta, + ...queryPresets.oneTimeFetch, + ...queryPresets.noRetry, + enabled: !profile?.restricted && flags.lkeEnterprise?.enabled, + }); +}; + export const useMutateAccount = () => { const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); diff --git a/packages/manager/src/queries/account/queries.ts b/packages/manager/src/queries/account/queries.ts index a874bd50381..d3629773c99 100644 --- a/packages/manager/src/queries/account/queries.ts +++ b/packages/manager/src/queries/account/queries.ts @@ -3,6 +3,7 @@ import { getAccountBeta, getAccountBetas, getAccountInfo, + getAccountInfoBeta, getAccountLogins, getAccountMaintenance, getAccountSettings, @@ -32,6 +33,13 @@ export const accountQueries = createQueryKeys('account', { queryFn: getAccountInfo, queryKey: null, }, + /** + * @TODO LKE-E - M3-8838: Clean up after released to GA, if not otherwise in use + */ + accountBeta: { + queryFn: getAccountInfoBeta, + queryKey: null, + }, agreements: { queryFn: getAccountAgreements, queryKey: null,