diff --git a/packages/api-v4/.changeset/pr-10677-upcoming-features-1721062200460.md b/packages/api-v4/.changeset/pr-10677-upcoming-features-1721062200460.md new file mode 100644 index 00000000000..50609509e80 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10677-upcoming-features-1721062200460.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Added new /v4/object-storage/endpoints endpoint ([#10677](https://github.com/linode/manager/pull/10677)) diff --git a/packages/api-v4/src/object-storage/objects.ts b/packages/api-v4/src/object-storage/objects.ts index 5d27fbb563f..fd567d45b9b 100644 --- a/packages/api-v4/src/object-storage/objects.ts +++ b/packages/api-v4/src/object-storage/objects.ts @@ -1,12 +1,21 @@ import { API_ROOT } from '../constants'; -import Request, { setData, setMethod, setURL } from '../request'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; import { ACLType, + ObjectStorageEndpointsResponse, ObjectStorageObjectACL, ObjectStorageObjectURL, ObjectStorageObjectURLOptions, } from './types'; +import type { ResourcePage, RequestOptions } from '../types'; + /** * Gets a URL to upload/download/delete Objects from a Bucket. */ @@ -70,3 +79,11 @@ export const updateObjectACL = ( ), setData({ acl, name }) ); + +export const getObjectStorageEndpoints = ({ filter, params }: RequestOptions) => + Request>( + setMethod('GET'), + setURL(`${API_ROOT}/object-storage/endpoints`), + setParams(params), + setXFilter(filter) + ); diff --git a/packages/api-v4/src/object-storage/types.ts b/packages/api-v4/src/object-storage/types.ts index a537230de08..f8877647df1 100644 --- a/packages/api-v4/src/object-storage/types.ts +++ b/packages/api-v4/src/object-storage/types.ts @@ -1,6 +1,9 @@ -export interface RegionS3EndpointAndID { +export type ObjEndpointTypes = 'E0' | 'E1' | 'E2' | 'E3'; + +export interface ObjAccessKeyRegionsResponse { id: string; s3_endpoint: string; + endpoint_type?: ObjEndpointTypes; } export interface ObjectStorageKey { @@ -9,7 +12,7 @@ export interface ObjectStorageKey { id: number; label: string; limited: boolean; - regions: RegionS3EndpointAndID[]; + regions: ObjAccessKeyRegionsResponse[]; secret_key: string; } @@ -45,6 +48,7 @@ export interface ObjectStorageBucketRequestPayload { cors_enabled?: boolean; label: string; region?: string; + endpoint_type?: ObjEndpointTypes; /* @TODO OBJ Multicluster: 'region' will become required, and the 'cluster' field will be deprecated once the feature is fully rolled out in production as part of the process of cleaning up the 'objMultiCluster' @@ -73,6 +77,8 @@ export interface ObjectStorageBucket { hostname: string; objects: number; size: number; // Size of bucket in bytes + s3_endpoint?: string; + endpoint_type?: ObjEndpointTypes; } export interface ObjectStorageObject { @@ -88,6 +94,12 @@ export interface ObjectStorageObjectURL { url: string; } +export interface ObjectStorageEndpointsResponse { + region: string; + endpoint_type: ObjEndpointTypes; + s3_endpoint: string | null; +} + export type ACLType = | 'private' | 'public-read' @@ -95,9 +107,10 @@ export type ACLType = | 'public-read-write' | 'custom'; +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. export interface ObjectStorageObjectACL { - acl: ACLType; - acl_xml: string; + acl: ACLType | null; + acl_xml: string | null; } export interface ObjectStorageObjectURLOptions { @@ -142,8 +155,9 @@ export interface ObjectStorageBucketSSLRequest { private_key: string; } +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. export interface ObjectStorageBucketSSLResponse { - ssl: boolean; + ssl: boolean | null; } export interface ObjectStorageBucketAccessRequest { @@ -151,9 +165,15 @@ export interface ObjectStorageBucketAccessRequest { cors_enabled?: boolean; } +export interface ObjBucketAccessPayload { + acl: ACLType; + cors_enabled?: boolean; +} + +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. export interface ObjectStorageBucketAccessResponse { acl: ACLType; acl_xml: string; - cors_enabled: boolean; - cors_xml: string; + cors_enabled: boolean | null; + cors_xml: string | null; } diff --git a/packages/manager/.changeset/pr-10677-upcoming-features-1721062431452.md b/packages/manager/.changeset/pr-10677-upcoming-features-1721062431452.md new file mode 100644 index 00000000000..859c857cfac --- /dev/null +++ b/packages/manager/.changeset/pr-10677-upcoming-features-1721062431452.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Object Storage Gen2 cors_enabled and type updates ([#10677](https://github.com/linode/manager/pull/10677)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 3806fb0ebb0..7c9b5c8a799 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -2,7 +2,7 @@ * @file End-to-end tests for Object Storage Access Key operations. */ -import { objectStorageBucketFactory } from 'src/factories/objectStorage'; +import { createObjectStorageBucketFactory } from 'src/factories/objectStorage'; import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; import { @@ -120,7 +120,7 @@ describe('object storage access key end-to-end tests', () => { it('can create an access key with limited access - e2e', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-east-1'; - const bucketRequest = objectStorageBucketFactory.build({ + const bucketRequest = createObjectStorageBucketFactory.build({ label: bucketLabel, cluster: bucketCluster, // Default factory sets `cluster` and `region`, but API does not accept `region` yet. diff --git a/packages/manager/src/factories/objectStorage.ts b/packages/manager/src/factories/objectStorage.ts index 6d5d7411f34..00ed05f4706 100644 --- a/packages/manager/src/factories/objectStorage.ts +++ b/packages/manager/src/factories/objectStorage.ts @@ -1,10 +1,12 @@ -import { +import Factory from 'src/factories/factoryProxy'; + +import type { ObjectStorageBucket, + ObjectStorageBucketRequestPayload, ObjectStorageCluster, ObjectStorageKey, ObjectStorageObject, } from '@linode/api-v4/lib/object-storage/types'; -import Factory from 'src/factories/factoryProxy'; export const objectStorageBucketFactory = Factory.Sync.makeFactory( { @@ -20,6 +22,17 @@ export const objectStorageBucketFactory = Factory.Sync.makeFactory( + { + acl: 'private', + cluster: 'us-east-1', + cors_enabled: true, + endpoint_type: 'E1', + label: Factory.each((i) => `obj-bucket-${i}`), + region: 'us-east', + } +); + export const objectStorageClusterFactory = Factory.Sync.makeFactory( { domain: Factory.each((id) => `cluster-${id}.linodeobjects.com`), diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx index 3a609d63c13..66a83e726d8 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx @@ -1,6 +1,6 @@ import { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; import { APIError } from '@linode/api-v4/lib/types'; import { styled } from '@mui/material/styles'; @@ -41,7 +41,7 @@ export const AccessKeyTable = (props: AccessKeyTableProps) => { const [showHostNamesDrawer, setShowHostNamesDrawers] = useState( false ); - const [hostNames, setHostNames] = useState([]); + const [hostNames, setHostNames] = useState([]); const flags = useFlags(); const { account } = useAccountManagement(); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx index 241f0b43d77..0d0e6d5a48a 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx @@ -1,6 +1,6 @@ import { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; import { APIError } from '@linode/api-v4/lib/types'; import React from 'react'; @@ -19,7 +19,7 @@ type Props = { isRestrictedUser: boolean; openDrawer: OpenAccessDrawer; openRevokeDialog: (objectStorageKey: ObjectStorageKey) => void; - setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; + setHostNames: (hostNames: ObjAccessKeyRegionsResponse[]) => void; setShowHostNamesDrawers: (show: boolean) => void; }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx index 828570409dd..4b1c3e2b460 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx @@ -1,6 +1,6 @@ import { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -20,7 +20,7 @@ import { HostNameTableCell } from './HostNameTableCell'; type Props = { openDrawer: OpenAccessDrawer; openRevokeDialog: (storageKeyData: ObjectStorageKey) => void; - setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; + setHostNames: (hostNames: ObjAccessKeyRegionsResponse[]) => void; setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index 3bfbd4faf08..ae01805cfdc 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -9,11 +9,11 @@ import { getRegionsByRegionId } from 'src/utilities/regions'; import type { ObjectStorageKey, - RegionS3EndpointAndID, + ObjAccessKeyRegionsResponse, } from '@linode/api-v4/lib/object-storage'; type Props = { - setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; + setHostNames: (hostNames: ObjAccessKeyRegionsResponse[]) => void; setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx index 1494590163d..28c5ae88dd7 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx @@ -1,4 +1,4 @@ -import { RegionS3EndpointAndID } from '@linode/api-v4'; +import { ObjAccessKeyRegionsResponse } from '@linode/api-v4'; import * as React from 'react'; import { Box } from 'src/components/Box'; @@ -12,7 +12,7 @@ import { CopyAllHostnames } from './CopyAllHostnames'; interface Props { onClose: () => void; open: boolean; - regions: RegionS3EndpointAndID[]; + regions: ObjAccessKeyRegionsResponse[]; } export const HostNamesDrawer = (props: Props) => { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx index 01b34c2d4e3..0cd00cdfc1a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx @@ -1,5 +1,4 @@ -import { ACLType } from '@linode/api-v4/lib/object-storage'; -import { Theme, styled } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -18,18 +17,26 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { bucketACLOptions, objectACLOptions } from '../utilities'; import { copy } from './AccessSelect.data'; -interface AccessPayload { - acl: ACLType; - cors_enabled?: boolean; -} +import type { + ACLType, + ObjBucketAccessPayload, + ObjectStorageObjectACL, +} from '@linode/api-v4/lib/object-storage'; +import type { Theme } from '@mui/material/styles'; export interface Props { - getAccess: () => Promise; + getAccess: () => Promise; name: string; updateAccess: (acl: ACLType, cors_enabled?: boolean) => Promise<{}>; variant: 'bucket' | 'object'; } +function isObjBucketAccessPayload( + payload: ObjBucketAccessPayload | ObjectStorageObjectACL +): payload is ObjBucketAccessPayload { + return 'cors_enabled' in payload; +} + export const AccessSelect = React.memo((props: Props) => { const { getAccess, name, updateAccess, variant } = props; // Access data for this Object (from the API). @@ -40,7 +47,7 @@ export const AccessSelect = React.memo((props: Props) => { // The ACL Option currently selected in the component. const [selectedACL, setSelectedACL] = React.useState(null); // The CORS Option currently selected in the component. - const [selectedCORSOption, setSelectedCORSOption] = React.useState(true); + const [selectedCORSOption, setSelectedCORSOption] = React.useState(true); // TODO: OBJGen2 - We need to handle this in upcoming PR // State for submitting access options. const [updateAccessLoading, setUpdateAccessLoading] = React.useState(false); const [updateAccessError, setUpdateAccessError] = React.useState(''); @@ -55,17 +62,22 @@ export const AccessSelect = React.memo((props: Props) => { setUpdateAccessSuccess(false); setAccessLoading(true); getAccess() - .then(({ acl, cors_enabled }) => { + .then((payload) => { setAccessLoading(false); + const { acl } = payload; // Don't show "public-read-write" for Objects here; use "custom" instead // since "public-read-write" Objects are basically the same as "public-read". const _acl = variant === 'object' && acl === 'public-read-write' ? 'custom' : acl; setACLData(_acl); setSelectedACL(_acl); - if (typeof cors_enabled !== 'undefined') { - setCORSData(cors_enabled); - setSelectedCORSOption(cors_enabled); + + if (isObjBucketAccessPayload(payload)) { + const { cors_enabled } = payload; + if (typeof cors_enabled === 'boolean') { + setCORSData(cors_enabled); + setSelectedCORSOption(cors_enabled); + } } }) .catch((err) => { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx index af95b960e6c..dade0370eb2 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx @@ -1,5 +1,4 @@ import { - ACLType, getBucketAccess, updateBucketAccess, } from '@linode/api-v4/lib/object-storage'; @@ -11,6 +10,8 @@ import { Typography } from 'src/components/Typography'; import { AccessSelect } from './AccessSelect'; +import type { ACLType } from '@linode/api-v4/lib/object-storage'; + export const StyledRootContainer = styled(Paper, { label: 'StyledRootContainer', })(({ theme }) => ({ diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx index cd9f2f31979..31fe5a798e0 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx @@ -1,5 +1,4 @@ import { - ACLType, getObjectACL, updateObjectACL, } from '@linode/api-v4/lib/object-storage'; @@ -18,6 +17,8 @@ import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from './AccessSelect'; +import type { ACLType } from '@linode/api-v4/lib/object-storage'; + export interface ObjectDetailsDrawerProps { bucketName: string; clusterId: string; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 70fb01f55b8..efaeee2688a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -1,6 +1,4 @@ -import { Region } from '@linode/api-v4'; import { - ACLType, getBucketAccess, updateBucketAccess, } from '@linode/api-v4/lib/object-storage'; @@ -24,6 +22,9 @@ import { truncateMiddle } from 'src/utilities/truncate'; import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from '../BucketDetail/AccessSelect'; + +import type { Region } from '@linode/api-v4'; +import type { ACLType } from '@linode/api-v4/lib/object-storage'; export interface BucketDetailsDrawerProps { bucketLabel?: string; bucketRegion?: Region; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 71fb9116ef1..7610a54c6fe 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -112,6 +112,7 @@ export const CreateBucketDrawer = (props: Props) => { const formik = useFormik({ initialValues: { cluster: '', + cors_enabled: true, // For Gen1, CORS is always enabled label: '', }, async onSubmit(values) { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 4340b64e5f6..40fa2049e17 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -95,6 +95,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const formik = useFormik({ initialValues: { + cors_enabled: true, // Gen1 = true, Gen2 = false @TODO: OBJGen2 - Future PR will implement this... label: '', region: '', }, diff --git a/packages/validation/.changeset/pr-10677-upcoming-features-1721062312524.md b/packages/validation/.changeset/pr-10677-upcoming-features-1721062312524.md new file mode 100644 index 00000000000..31abaf0ee0c --- /dev/null +++ b/packages/validation/.changeset/pr-10677-upcoming-features-1721062312524.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Updated create bucket schema validation for endpoint_type and cors_enabled ([#10677](https://github.com/linode/manager/pull/10677)) diff --git a/packages/validation/src/buckets.schema.ts b/packages/validation/src/buckets.schema.ts index 90f248ceb4b..b8128239614 100644 --- a/packages/validation/src/buckets.schema.ts +++ b/packages/validation/src/buckets.schema.ts @@ -1,24 +1,46 @@ import { boolean, object, string } from 'yup'; -export const CreateBucketSchema = object().shape( - { - label: string() - .required('Label is required.') - .matches(/^\S*$/, 'Label must not contain spaces.') - .ensure() - .min(3, 'Label must be between 3 and 63 characters.') - .max(63, 'Label must be between 3 and 63 characters.'), - cluster: string().when('region', { - is: (region: string) => !region || region.length === 0, - then: string().required('Cluster is required.'), - }), - region: string().when('cluster', { - is: (cluster: string) => !cluster || cluster.length === 0, - then: string().required('Region is required.'), - }), - }, - [['cluster', 'region']] -); +const ENDPOINT_TYPES = ['E0', 'E1', 'E2', 'E3'] as const; + +export const CreateBucketSchema = object() + .shape( + { + label: string() + .required('Label is required.') + .matches(/^\S*$/, 'Label must not contain spaces.') + .min(3, 'Label must be between 3 and 63 characters.') + .max(63, 'Label must be between 3 and 63 characters.'), + cluster: string().when('region', { + is: (region: string) => !region || region.length === 0, + then: string().required('Cluster is required.'), + }), + region: string().when('cluster', { + is: (cluster: string) => !cluster || cluster.length === 0, + then: string().required('Region is required.'), + }), + endpoint_type: string() + .oneOf([...ENDPOINT_TYPES]) + .notRequired(), + cors_enabled: boolean().notRequired(), + }, + [['cluster', 'region']] + ) + .test('cors-enabled-check', 'Invalid CORS configuration.', function (value) { + const { endpoint_type, cors_enabled } = value; + if ((endpoint_type === 'E0' || endpoint_type === 'E1') && !cors_enabled) { + return this.createError({ + path: 'cors_enabled', + message: 'CORS must be enabled for endpoint type E0 or E1.', + }); + } + if ((endpoint_type === 'E2' || endpoint_type === 'E3') && cors_enabled) { + return this.createError({ + path: 'cors_enabled', + message: 'CORS must be disabled for endpoint type E2 or E3.', + }); + } + return true; + }); export const UploadCertificateSchema = object({ certificate: string().required('Certificate is required.'),