diff --git a/package-lock.json b/package-lock.json index e9fdc59..32c52c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2753,7 +2753,6 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -4608,7 +4607,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4681,7 +4679,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -5162,7 +5159,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5650,7 +5646,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", @@ -6567,7 +6562,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6628,7 +6622,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7910,7 +7903,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11438,7 +11430,6 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12114,7 +12105,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13099,7 +13089,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13239,7 +13228,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13346,7 +13334,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/common/aliyunClient/index.ts b/src/common/aliyunClient/index.ts index 0b6ecfa..fad677c 100644 --- a/src/common/aliyunClient/index.ts +++ b/src/common/aliyunClient/index.ts @@ -13,9 +13,11 @@ import { createRamOperations } from './ramOperations'; import { createEcsOperations } from './ecsOperations'; import { createNasOperations } from './nasOperations'; import { createApigwOperations } from './apigwOperations'; +import { createOssOperations } from './ossOperations'; export * from './types'; export * from './apigwOperations'; +export * from './ossOperations'; const initializeSdkClients = (context: Context) => { const baseConfig = { @@ -76,7 +78,7 @@ export const createAliyunClient = (context: Context) => { ram: createRamOperations(sdkClients.ram), ecs: createEcsOperations(sdkClients.ecs, context), nas: createNasOperations(sdkClients.nas), - oss: sdkClients.oss, + oss: createOssOperations(sdkClients.oss, context.region), apigw: createApigwOperations(sdkClients.apigw), }; }; diff --git a/src/common/aliyunClient/ossOperations.ts b/src/common/aliyunClient/ossOperations.ts new file mode 100644 index 0000000..60d84c9 --- /dev/null +++ b/src/common/aliyunClient/ossOperations.ts @@ -0,0 +1,277 @@ +import OSS from 'ali-oss'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + BucketACL, + CommonBucketInfo, + BucketWebsiteConfig, + BucketLoggingConfig, + BucketCorsRule, + BucketLifecycleRule, + BucketOwner, +} from '../../stack/bucketTypes'; + +export type OssBucketConfig = { + bucketName: string; + acl?: BucketACL; + websiteConfig?: BucketWebsiteConfig; + storageClass?: string; +}; + +export type OssBucketInfo = CommonBucketInfo; + +type OssSdkClient = OSS; + +export const createOssOperations = (ossClient: OssSdkClient, region: string) => { + const useBucket = (bucketName: string) => { + ossClient.useBucket(bucketName); + }; + + return { + createBucket: async (config: OssBucketConfig): Promise => { + if (config.storageClass) { + await ossClient.putBucket(config.bucketName, { + storageClass: config.storageClass as OSS.StorageType, + } as OSS.PutBucketOptions); + } else { + await ossClient.putBucket(config.bucketName); + } + + // Set ACL if specified + if (config.acl) { + useBucket(config.bucketName); + await ossClient.putBucketACL(config.bucketName, config.acl); + } + + // Set website configuration if specified + if (config.websiteConfig) { + useBucket(config.bucketName); + await ossClient.putBucketWebsite(config.bucketName, { + index: config.websiteConfig.indexDocument, + error: config.websiteConfig.errorDocument, + }); + } + + return { + name: config.bucketName, + location: `oss-${region}`, + acl: config.acl, + websiteConfig: config.websiteConfig, + storageClass: config.storageClass, + }; + }, + + getBucket: async (bucketName: string): Promise => { + try { + useBucket(bucketName); + + // Get bucket info + const infoResult = await ossClient.getBucketInfo(bucketName); + const bucket = infoResult.bucket; + + // Get ACL + let acl: string | undefined; + try { + const aclResult = await ossClient.getBucketACL(bucketName); + acl = aclResult.acl; + } catch { + // ACL might not be accessible + } + + // Get website config + let websiteConfig: BucketWebsiteConfig | undefined; + try { + const websiteResult = await ossClient.getBucketWebsite(bucketName); + if (websiteResult.index) { + websiteConfig = { + indexDocument: websiteResult.index, + errorDocument: websiteResult.error, + }; + } + } catch { + // Website config might not exist + } + + // Get logging config + let loggingConfig: BucketLoggingConfig | undefined; + try { + const loggingResult = await ossClient.getBucketLogging(bucketName); + if (loggingResult.enable && loggingResult.prefix) { + loggingConfig = { + targetPrefix: loggingResult.prefix, + }; + } + } catch { + // Logging config might not exist + } + + // Get CORS rules + let corsRules: BucketCorsRule[] | undefined; + try { + const corsResult = await ossClient.getBucketCORS(bucketName); + if (corsResult.rules && corsResult.rules.length > 0) { + corsRules = corsResult.rules.map((rule) => ({ + allowedOrigins: Array.isArray(rule.allowedOrigin) + ? rule.allowedOrigin + : [rule.allowedOrigin], + allowedMethods: Array.isArray(rule.allowedMethod) + ? rule.allowedMethod + : [rule.allowedMethod], + allowedHeaders: rule.allowedHeader + ? Array.isArray(rule.allowedHeader) + ? rule.allowedHeader + : [rule.allowedHeader] + : undefined, + exposeHeaders: rule.exposeHeader + ? Array.isArray(rule.exposeHeader) + ? rule.exposeHeader + : [rule.exposeHeader] + : undefined, + maxAgeSeconds: + typeof rule.maxAgeSeconds === 'number' ? rule.maxAgeSeconds : undefined, + })); + } + } catch { + // CORS config might not exist + } + + // Get lifecycle rules + let lifecycleRules: BucketLifecycleRule[] | undefined; + try { + const lifecycleResult = await ossClient.getBucketLifecycle(bucketName); + if (lifecycleResult.rules && lifecycleResult.rules.length > 0) { + lifecycleRules = lifecycleResult.rules.map((rule) => ({ + id: rule.id, + status: rule.status, + prefix: rule.prefix, + expiration: rule.days + ? { + days: typeof rule.days === 'number' ? rule.days : parseInt(rule.days, 10), + } + : rule.date + ? { + date: rule.date, + } + : undefined, + })); + } + } catch { + // Lifecycle config might not exist + } + + // Build owner info + const owner: BucketOwner | undefined = bucket?.Owner + ? { + id: bucket.Owner.ID, + displayName: bucket.Owner.DisplayName, + } + : undefined; + + return { + name: bucketName, + location: bucket?.Location, + creationDate: bucket?.CreationDate, + storageClass: bucket?.StorageClass, + dataRedundancyType: bucket?.DataRedundancyType as 'LRS' | 'ZRS' | undefined, + resourceGroupId: bucket?.ResourceGroupId, + comment: bucket?.Comment, + owner, + blockPublicAccess: bucket?.BlockPublicAccess, + accessMonitorStatus: bucket?.AccessMonitor as 'Enabled' | 'Disabled' | undefined, + acl, + websiteConfig, + loggingConfig, + corsRules, + lifecycleRules, + }; + } catch (error: unknown) { + if ( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'NoSuchBucket' || error.code === 'AccessDenied') + ) { + return null; + } + throw error; + } + }, + + updateBucketAcl: async (bucketName: string, acl: BucketACL): Promise => { + useBucket(bucketName); + await ossClient.putBucketACL(bucketName, acl); + }, + + updateBucketWebsite: async ( + bucketName: string, + websiteConfig: BucketWebsiteConfig, + ): Promise => { + useBucket(bucketName); + await ossClient.putBucketWebsite(bucketName, { + index: websiteConfig.indexDocument, + error: websiteConfig.errorDocument, + }); + }, + + deleteBucketWebsite: async (bucketName: string): Promise => { + useBucket(bucketName); + await ossClient.deleteBucketWebsite(bucketName); + }, + + deleteBucket: async (bucketName: string): Promise => { + useBucket(bucketName); + + // List and delete all objects first + try { + let marker: string | undefined; + do { + const listResult = await ossClient.list( + { + 'max-keys': 1000, + marker, + }, + {}, + ); + + if (listResult.objects && listResult.objects.length > 0) { + const keys = listResult.objects.map((obj) => obj.name); + await ossClient.deleteMulti(keys, { quiet: true }); + } + + marker = listResult.isTruncated ? listResult.nextMarker : undefined; + } while (marker); + } catch { + // Ignore errors when listing/deleting objects + } + + await ossClient.deleteBucket(bucketName); + }, + + uploadFiles: async (bucketName: string, sourcePath: string): Promise => { + useBucket(bucketName); + + const uploadDirectory = async (dirPath: string, prefix: string = '') => { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const ossKey = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + await uploadDirectory(fullPath, ossKey); + } else if (entry.isFile()) { + await ossClient.put(ossKey, fullPath); + } + } + }; + + const stats = fs.statSync(sourcePath); + if (stats.isDirectory()) { + await uploadDirectory(sourcePath); + } else { + const fileName = path.basename(sourcePath); + await ossClient.put(fileName, sourcePath); + } + }, + }; +}; diff --git a/src/stack/aliyunStack/index.ts b/src/stack/aliyunStack/index.ts index 42bcecf..7a562b5 100644 --- a/src/stack/aliyunStack/index.ts +++ b/src/stack/aliyunStack/index.ts @@ -6,3 +6,7 @@ export * from './apigwTypes'; export * from './apigwResource'; export * from './apigwPlanner'; export * from './apigwExecutor'; +export * from './ossTypes'; +export * from './ossResource'; +export * from './ossPlanner'; +export * from './ossExecutor'; diff --git a/src/stack/aliyunStack/ossExecutor.ts b/src/stack/aliyunStack/ossExecutor.ts new file mode 100644 index 0000000..a390f44 --- /dev/null +++ b/src/stack/aliyunStack/ossExecutor.ts @@ -0,0 +1,76 @@ +import { Context, BucketDomain, Plan, StateFile } from '../../types'; +import { createBucketResource, deleteBucketResource, updateBucketResource } from './ossResource'; +import { logger } from '../../common'; +import { getResource } from '../../common/stateManager'; + +export const executeBucketPlan = async ( + context: Context, + plan: Plan, + buckets: Array | undefined, + initialState: StateFile, +): Promise => { + const bucketsMap = new Map( + buckets?.map((bucket) => [`buckets.${bucket.key}`, bucket]) ?? [], + ); + let currentState = initialState; + + for (const item of plan.items) { + if (item.action === 'noop') { + logger.info(`No changes for ${item.logicalId}`); + continue; + } + + try { + switch (item.action) { + case 'create': { + const bucket = bucketsMap.get(item.logicalId); + if (!bucket) { + throw new Error(`Bucket not found for logical ID: ${item.logicalId}`); + } + logger.info(`Creating bucket: ${bucket.name}`); + currentState = await createBucketResource(context, bucket, currentState); + logger.info(`Successfully created bucket: ${bucket.name}`); + break; + } + + case 'update': { + const bucket = bucketsMap.get(item.logicalId); + if (!bucket) { + throw new Error(`Bucket not found for logical ID: ${item.logicalId}`); + } + logger.info(`Updating bucket: ${bucket.name}`); + currentState = await updateBucketResource(context, bucket, currentState); + logger.info(`Successfully updated bucket: ${bucket.name}`); + break; + } + + case 'delete': { + const state = getResource(currentState, item.logicalId); + if (!state) { + logger.warn(`State not found for ${item.logicalId}, skipping deletion`); + continue; + } + // Extract bucket name from definition + const bucketName = state.definition.bucketName as string; + logger.info(`Deleting bucket: ${bucketName}`); + currentState = await deleteBucketResource( + context, + bucketName, + item.logicalId, + currentState, + ); + logger.info(`Successfully deleted bucket: ${bucketName}`); + break; + } + + default: + logger.warn(`Unknown action: ${item.action} for ${item.logicalId}`); + } + } catch (error) { + logger.error(`Failed to execute ${item.action} for ${item.logicalId}: ${error}`); + throw error; + } + } + + return currentState; +}; diff --git a/src/stack/aliyunStack/ossPlanner.ts b/src/stack/aliyunStack/ossPlanner.ts new file mode 100644 index 0000000..f28d46b --- /dev/null +++ b/src/stack/aliyunStack/ossPlanner.ts @@ -0,0 +1,90 @@ +import { Context, BucketDomain, Plan, PlanItem, StateFile, ResourceAttributes } from '../../types'; +import { createAliyunClient } from '../../common/aliyunClient'; +import { bucketToOssBucketConfig, extractOssBucketDefinition } from './ossTypes'; +import { getAllResources, getResource } from '../../common/stateManager'; +import { attributesEqual } from '../../common/hashUtils'; + +const planBucketDeletion = (logicalId: string, definition: ResourceAttributes): PlanItem => ({ + logicalId, + action: 'delete', + resourceType: 'ALIYUN_OSS_BUCKET', + changes: { before: definition }, +}); + +export const generateBucketPlan = async ( + context: Context, + state: StateFile, + buckets: Array | undefined, +): Promise => { + if (!buckets || buckets.length === 0) { + const allStates = getAllResources(state); + const items = Object.entries(allStates) + .filter(([logicalId]) => logicalId.startsWith('buckets.')) + .map(([logicalId, resourceState]) => planBucketDeletion(logicalId, resourceState.definition)); + return { items }; + } + + const desiredLogicalIds = new Set(buckets.map((bucket) => `buckets.${bucket.key}`)); + + const bucketItems = await Promise.all( + buckets.map(async (bucket): Promise => { + const logicalId = `buckets.${bucket.key}`; + const currentState = getResource(state, logicalId); + const config = bucketToOssBucketConfig(bucket); + const desiredDefinition = extractOssBucketDefinition(config); + + if (!currentState) { + return { + logicalId, + action: 'create', + resourceType: 'ALIYUN_OSS_BUCKET', + changes: { after: desiredDefinition }, + }; + } + + try { + const client = createAliyunClient(context); + const remoteBucket = await client.oss.getBucket(bucket.name); + + if (!remoteBucket) { + return { + logicalId, + action: 'create', + resourceType: 'ALIYUN_OSS_BUCKET', + changes: { before: currentState.definition, after: desiredDefinition }, + drifted: true, + }; + } + + const currentDefinition = currentState.definition || {}; + const definitionChanged = !attributesEqual(currentDefinition, desiredDefinition); + + if (definitionChanged) { + return { + logicalId, + action: 'update', + resourceType: 'ALIYUN_OSS_BUCKET', + changes: { before: currentDefinition, after: desiredDefinition }, + drifted: true, + }; + } + + return { logicalId, action: 'noop', resourceType: 'ALIYUN_OSS_BUCKET' }; + } catch { + return { + logicalId, + action: 'create', + resourceType: 'ALIYUN_OSS_BUCKET', + changes: { before: currentState.definition, after: desiredDefinition }, + }; + } + }), + ); + + const allStates = getAllResources(state); + const deletionItems = Object.entries(allStates) + .filter(([logicalId]) => logicalId.startsWith('buckets.') && !desiredLogicalIds.has(logicalId)) + .map(([logicalId, resourceState]) => planBucketDeletion(logicalId, resourceState.definition)); + + return { items: [...bucketItems, ...deletionItems] }; +}; diff --git a/src/stack/aliyunStack/ossResource.ts b/src/stack/aliyunStack/ossResource.ts new file mode 100644 index 0000000..c6a64f9 --- /dev/null +++ b/src/stack/aliyunStack/ossResource.ts @@ -0,0 +1,195 @@ +import { createAliyunClient } from '../../common/aliyunClient'; +import { OssBucketInfo } from '../../common/aliyunClient/ossOperations'; +import { setResource, removeResource } from '../../common'; +import { Context, BucketDomain, ResourceState, StateFile } from '../../types'; +import { bucketToOssBucketConfig, extractOssBucketDefinition } from './ossTypes'; +import { CommonBucketInstance } from '../bucketTypes'; +import path from 'node:path'; + +const buildOssInstanceFromProvider = (info: OssBucketInfo, arn: string): CommonBucketInstance => { + return { + type: 'ALIYUN_OSS_BUCKET', + arn, + id: info.name, + bucketName: info.name, + location: info.location ?? null, + creationDate: info.creationDate ?? null, + storageClass: info.storageClass ?? null, + dataRedundancyType: info.dataRedundancyType ?? null, + resourceGroupId: info.resourceGroupId ?? null, + comment: info.comment ?? null, + owner: info.owner + ? { + id: info.owner.id ?? null, + displayName: info.owner.displayName ?? null, + } + : undefined, + blockPublicAccess: info.blockPublicAccess ?? null, + accessMonitorStatus: info.accessMonitorStatus ?? null, + acl: info.acl ?? null, + websiteConfig: info.websiteConfig + ? { + indexDocument: info.websiteConfig.indexDocument ?? null, + errorDocument: info.websiteConfig.errorDocument ?? null, + } + : undefined, + loggingConfig: info.loggingConfig + ? { + targetBucket: info.loggingConfig.targetBucket ?? null, + targetPrefix: info.loggingConfig.targetPrefix ?? null, + } + : undefined, + corsRules: info.corsRules?.map((rule) => ({ + id: rule.id ?? null, + allowedOrigins: rule.allowedOrigins ?? [], + allowedMethods: rule.allowedMethods ?? [], + allowedHeaders: rule.allowedHeaders ?? [], + exposeHeaders: rule.exposeHeaders ?? [], + maxAgeSeconds: rule.maxAgeSeconds ?? null, + })), + lifecycleRules: info.lifecycleRules?.map((rule) => ({ + id: rule.id ?? null, + status: rule.status ?? null, + prefix: rule.prefix ?? null, + expiration: rule.expiration + ? { + days: rule.expiration.days ?? null, + date: rule.expiration.date ?? null, + expiredObjectDeleteMarker: rule.expiration.expiredObjectDeleteMarker ?? null, + } + : undefined, + transition: rule.transition + ? { + days: rule.transition.days ?? null, + date: rule.transition.date ?? null, + storageClass: rule.transition.storageClass ?? null, + } + : undefined, + })), + versioningStatus: info.versioningConfig?.status ?? null, + encryptionConfig: info.encryptionConfig + ? { + sseAlgorithm: info.encryptionConfig.sseAlgorithm ?? null, + kmsMasterKeyId: info.encryptionConfig.kmsMasterKeyId ?? null, + kmsDataEncryption: info.encryptionConfig.kmsDataEncryption ?? null, + } + : undefined, + transferAccelerationStatus: info.transferAccelerationStatus ?? null, + replicationRules: info.replicationRules?.map((rule) => ({ + id: rule.id ?? null, + status: rule.status ?? null, + prefix: rule.prefix ?? null, + destination: rule.destination + ? { + bucket: rule.destination.bucket ?? null, + storageClass: rule.destination.storageClass ?? null, + } + : undefined, + })), + tags: info.tags?.map((tag) => ({ + key: tag.key ?? null, + value: tag.value ?? null, + })), + }; +}; + +export const createBucketResource = async ( + context: Context, + bucket: BucketDomain, + state: StateFile, +): Promise => { + const config = bucketToOssBucketConfig(bucket); + const client = createAliyunClient(context); + + await client.oss.createBucket({ + bucketName: config.bucketName, + acl: config.acl, + websiteConfig: config.websiteConfig, + storageClass: config.storageClass, + }); + + // Upload static files if code path is specified + if (bucket.website?.code) { + const codePath = path.resolve(process.cwd(), bucket.website.code); + await client.oss.uploadFiles(config.bucketName, codePath); + } + + // Refresh state from provider to get all attributes + const bucketInfo = await client.oss.getBucket(config.bucketName); + if (!bucketInfo) { + throw new Error(`Failed to refresh state for bucket: ${config.bucketName}`); + } + + const definition = extractOssBucketDefinition(config); + const arn = `arn:acs:oss:${context.region}:${context.accountId}:${config.bucketName}`; + const resourceState: ResourceState = { + mode: 'managed', + region: context.region, + definition, + instances: [buildOssInstanceFromProvider(bucketInfo, arn)], + lastUpdated: new Date().toISOString(), + }; + + const logicalId = `buckets.${bucket.key}`; + return setResource(state, logicalId, resourceState); +}; + +export const readBucketResource = async (context: Context, bucketName: string) => { + const client = createAliyunClient(context); + return await client.oss.getBucket(bucketName); +}; + +export const updateBucketResource = async ( + context: Context, + bucket: BucketDomain, + state: StateFile, +): Promise => { + const config = bucketToOssBucketConfig(bucket); + const client = createAliyunClient(context); + + // Update ACL if specified + if (config.acl) { + await client.oss.updateBucketAcl(config.bucketName, config.acl); + } + + // Update website configuration if specified + if (config.websiteConfig) { + await client.oss.updateBucketWebsite(config.bucketName, config.websiteConfig); + } + + // Upload static files if code path is specified + if (bucket.website?.code) { + const codePath = path.resolve(process.cwd(), bucket.website.code); + await client.oss.uploadFiles(config.bucketName, codePath); + } + + // Refresh state from provider to get all attributes + const bucketInfo = await client.oss.getBucket(config.bucketName); + if (!bucketInfo) { + throw new Error(`Failed to refresh state for bucket: ${config.bucketName}`); + } + + const definition = extractOssBucketDefinition(config); + const arn = `arn:acs:oss:${context.region}:${context.accountId}:${config.bucketName}`; + const resourceState: ResourceState = { + mode: 'managed', + region: context.region, + definition, + instances: [buildOssInstanceFromProvider(bucketInfo, arn)], + lastUpdated: new Date().toISOString(), + }; + + const logicalId = `buckets.${bucket.key}`; + return setResource(state, logicalId, resourceState); +}; + +export const deleteBucketResource = async ( + context: Context, + bucketName: string, + logicalId: string, + state: StateFile, +): Promise => { + const client = createAliyunClient(context); + await client.oss.deleteBucket(bucketName); + return removeResource(state, logicalId); +}; diff --git a/src/stack/aliyunStack/ossTypes.ts b/src/stack/aliyunStack/ossTypes.ts new file mode 100644 index 0000000..e04a52c --- /dev/null +++ b/src/stack/aliyunStack/ossTypes.ts @@ -0,0 +1,56 @@ +import { BucketAccessEnum, BucketDomain, ResourceAttributes } from '../../types'; +import { BucketACL, CommonBucketConfig } from '../bucketTypes'; + +export type OssBucketConfig = CommonBucketConfig; + +// Map from domain enum to provider ACL type +const aclMap: Record = { + [BucketAccessEnum.PRIVATE]: BucketACL.PRIVATE, + [BucketAccessEnum.PUBLIC_READ]: BucketACL.PUBLIC_READ, + [BucketAccessEnum.PUBLIC_READ_WRITE]: BucketACL.PUBLIC_READ_WRITE, +}; + +export const bucketToOssBucketConfig = (bucket: BucketDomain): OssBucketConfig => { + const config: OssBucketConfig = { + bucketName: bucket.name, + }; + + if (bucket.security?.acl) { + config.acl = aclMap[bucket.security.acl]; + } + + if (bucket.website) { + config.websiteConfig = { + indexDocument: bucket.website.index, + }; + + if (bucket.website.error_page) { + config.websiteConfig.errorDocument = bucket.website.error_page; + } + } + + if (bucket.storage?.class) { + config.storageClass = bucket.storage.class; + } + + if (bucket.website?.domain) { + config.domain = bucket.website.domain; + } + + return config; +}; + +export const extractOssBucketDefinition = (config: OssBucketConfig): ResourceAttributes => { + return { + bucketName: config.bucketName, + acl: config.acl ?? null, + websiteConfiguration: config.websiteConfig + ? { + indexDocument: config.websiteConfig.indexDocument, + errorDocument: config.websiteConfig.errorDocument ?? null, + } + : {}, + storageClass: config.storageClass ?? null, + domain: config.domain ?? null, + }; +}; diff --git a/src/stack/bucketTypes.ts b/src/stack/bucketTypes.ts new file mode 100644 index 0000000..a50a0db --- /dev/null +++ b/src/stack/bucketTypes.ts @@ -0,0 +1,228 @@ +/** + * Shared bucket types for OSS and COS providers. + * Both Aliyun OSS and Tencent COS share similar bucket concepts. + */ + +/** + * Bucket ACL (Access Control List) values. + * Common across cloud providers. + */ +export enum BucketACL { + PRIVATE = 'private', + PUBLIC_READ = 'public-read', + PUBLIC_READ_WRITE = 'public-read-write', +} + +/** + * Bucket storage class values. + */ +export type BucketStorageClass = 'Standard' | 'IA' | 'Archive' | 'ColdArchive' | string; + +/** + * Data redundancy type for bucket. + */ +export type BucketDataRedundancyType = 'LRS' | 'ZRS'; + +/** + * Website configuration for static website hosting. + */ +export type BucketWebsiteConfig = { + indexDocument: string; + errorDocument?: string; +}; + +/** + * CORS rule configuration. + */ +export type BucketCorsRule = { + id?: string; + allowedOrigins?: string[]; + allowedMethods?: string[]; + allowedHeaders?: string[]; + exposeHeaders?: string[]; + maxAgeSeconds?: number; +}; + +/** + * Lifecycle rule configuration. + */ +export type BucketLifecycleRule = { + id?: string; + status?: string; + prefix?: string; + expiration?: { + days?: number; + date?: string; + expiredObjectDeleteMarker?: boolean; + }; + transition?: { + days?: number; + date?: string; + storageClass?: string; + }; +}; + +/** + * Logging configuration. + */ +export type BucketLoggingConfig = { + targetBucket?: string; + targetPrefix?: string; +}; + +/** + * Versioning configuration. + */ +export type BucketVersioningConfig = { + status?: 'Enabled' | 'Suspended'; +}; + +/** + * Server-side encryption configuration. + */ +export type BucketEncryptionConfig = { + sseAlgorithm?: 'AES256' | 'KMS'; + kmsMasterKeyId?: string; + kmsDataEncryption?: 'SM4'; +}; + +/** + * Bucket owner information. + */ +export type BucketOwner = { + id?: string; + displayName?: string; +}; + +/** + * Replication rule configuration. + */ +export type BucketReplicationRule = { + id?: string; + status?: string; + prefix?: string; + destination?: { + bucket?: string; + storageClass?: string; + }; +}; + +/** + * Tag for bucket. + */ +export type BucketTag = { + key?: string; + value?: string; +}; + +/** + * Common bucket configuration used for creating/updating buckets. + */ +export type CommonBucketConfig = { + bucketName: string; + region?: string; + acl?: BucketACL; + storageClass?: BucketStorageClass; + websiteConfig?: BucketWebsiteConfig; + domain?: string; +}; + +/** + * Common bucket info returned from cloud providers. + */ +export type CommonBucketInfo = { + name: string; + location?: string; + creationDate?: string; + storageClass?: BucketStorageClass; + dataRedundancyType?: BucketDataRedundancyType; + resourceGroupId?: string; + comment?: string; + owner?: BucketOwner; + blockPublicAccess?: boolean; + accessMonitorStatus?: 'Enabled' | 'Disabled'; + acl?: string; + websiteConfig?: BucketWebsiteConfig; + loggingConfig?: BucketLoggingConfig; + corsRules?: BucketCorsRule[]; + lifecycleRules?: BucketLifecycleRule[]; + versioningConfig?: BucketVersioningConfig; + encryptionConfig?: BucketEncryptionConfig; + transferAccelerationStatus?: 'Enabled' | 'Disabled'; + replicationRules?: BucketReplicationRule[]; + tags?: BucketTag[]; +}; + +/** + * Common bucket instance stored in state. + */ +export type CommonBucketInstance = { + type: string; + arn: string; + id: string; + bucketName: string; + location?: string | null; + creationDate?: string | null; + storageClass?: string | null; + dataRedundancyType?: string | null; + resourceGroupId?: string | null; + comment?: string | null; + owner?: { + id?: string | null; + displayName?: string | null; + }; + blockPublicAccess?: boolean | null; + accessMonitorStatus?: string | null; + acl?: string | null; + websiteConfig?: { + indexDocument?: string | null; + errorDocument?: string | null; + }; + loggingConfig?: { + targetBucket?: string | null; + targetPrefix?: string | null; + }; + corsRules?: Array<{ + id?: string | null; + allowedOrigins?: string[]; + allowedMethods?: string[]; + allowedHeaders?: string[]; + exposeHeaders?: string[]; + maxAgeSeconds?: number | null; + }>; + lifecycleRules?: Array<{ + id?: string | null; + status?: string | null; + prefix?: string | null; + expiration?: { + days?: number | null; + date?: string | null; + expiredObjectDeleteMarker?: boolean | null; + }; + transition?: { + days?: number | null; + date?: string | null; + storageClass?: string | null; + }; + }>; + versioningStatus?: string | null; + encryptionConfig?: { + sseAlgorithm?: string | null; + kmsMasterKeyId?: string | null; + kmsDataEncryption?: string | null; + }; + transferAccelerationStatus?: string | null; + replicationRules?: Array<{ + id?: string | null; + status?: string | null; + prefix?: string | null; + destination?: { + bucket?: string | null; + storageClass?: string | null; + }; + }>; + tags?: Array<{ + key?: string | null; + value?: string | null; + }>; +}; diff --git a/src/types/domains/state.ts b/src/types/domains/state.ts index d7ef21a..ba773b5 100644 --- a/src/types/domains/state.ts +++ b/src/types/domains/state.ts @@ -6,6 +6,7 @@ export enum ResourceTypeEnum { ALIYUN_APIGW_GROUP = 'ALIYUN_APIGW_GROUP', ALIYUN_APIGW_API = 'ALIYUN_APIGW_API', ALIYUN_APIGW_DEPLOYMENT = 'ALIYUN_APIGW_DEPLOYMENT', + ALIYUN_OSS_BUCKET = 'ALIYUN_OSS_BUCKET', } export type ResourceAttributes = Record; diff --git a/tests/stack/aliyunStack/ossPlanner.test.ts b/tests/stack/aliyunStack/ossPlanner.test.ts new file mode 100644 index 0000000..ddb513e --- /dev/null +++ b/tests/stack/aliyunStack/ossPlanner.test.ts @@ -0,0 +1,240 @@ +import { ProviderEnum, setResource } from '../../../src/common'; +import { generateBucketPlan } from '../../../src/stack/aliyunStack/ossPlanner'; +import { BucketAccessEnum, BucketDomain, Context, CURRENT_STATE_VERSION } from '../../../src/types'; + +const initialState = { version: CURRENT_STATE_VERSION, provider: 'aliyun', resources: {} }; + +const mockOssOperations = { + createBucket: jest.fn(), + getBucket: jest.fn(), + updateBucketAcl: jest.fn(), + updateBucketWebsite: jest.fn(), + deleteBucketWebsite: jest.fn(), + deleteBucket: jest.fn(), + uploadFiles: jest.fn(), +}; + +jest.mock('../../../src/common/aliyunClient', () => ({ + createAliyunClient: () => ({ oss: mockOssOperations }), +})); + +describe('OSS Planner', () => { + const mockContext: Context = { + stage: 'default', + stackName: 'test-stack', + provider: ProviderEnum.ALIYUN, + region: 'cn-hangzhou', + accountId: '123456789012', + accessKeyId: 'test-key', + accessKeySecret: 'test-secret', + iacLocation: 'test.yml', + parameters: [], + stages: {}, + }; + + const testBucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + security: { + acl: BucketAccessEnum.PUBLIC_READ, + force_delete: false, + }, + website: { + index: 'index.html', + code: 'dist', + error_page: '404.html', + error_code: 404, + }, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('generateBucketPlan', () => { + it('should plan to create a new bucket when state is empty', async () => { + mockOssOperations.getBucket.mockResolvedValue(null); + + const state = initialState; + + const plan = await generateBucketPlan(mockContext, state, [testBucket]); + + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ + logicalId: 'buckets.test_bucket', + action: 'create', + resourceType: 'ALIYUN_OSS_BUCKET', + }); + expect(plan.items[0].changes?.after).toBeDefined(); + }); + + it('should plan no changes when bucket exists and matches state', async () => { + mockOssOperations.getBucket.mockResolvedValue({ + name: 'test-bucket', + acl: 'public-read', + websiteConfig: { + indexDocument: 'index.html', + errorDocument: '404.html', + }, + }); + + const state = setResource(initialState, 'buckets.test_bucket', { + mode: 'managed', + region: 'cn-hangzhou', + definition: { + bucketName: 'test-bucket', + acl: 'public-read', + websiteConfiguration: { + indexDocument: 'index.html', + errorDocument: '404.html', + }, + storageClass: null, + domain: null, + }, + instances: [ + { + arn: 'arn:acs:oss:cn-hangzhou:123456789012:test-bucket', + id: 'test-bucket', + bucketName: 'test-bucket', + }, + ], + lastUpdated: new Date().toISOString(), + }); + + const plan = await generateBucketPlan(mockContext, state, [testBucket]); + + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ + logicalId: 'buckets.test_bucket', + action: 'noop', + resourceType: 'ALIYUN_OSS_BUCKET', + }); + }); + + it('should plan to update when definition changes', async () => { + mockOssOperations.getBucket.mockResolvedValue({ + name: 'test-bucket', + acl: 'private', + websiteConfig: { + indexDocument: 'index.html', + errorDocument: '404.html', + }, + }); + + const state = setResource(initialState, 'buckets.test_bucket', { + mode: 'managed', + region: 'cn-hangzhou', + definition: { + bucketName: 'test-bucket', + acl: 'private', + websiteConfiguration: { + indexDocument: 'index.html', + errorDocument: '404.html', + }, + storageClass: null, + domain: null, + }, + instances: [ + { + arn: 'arn:acs:oss:cn-hangzhou:123456789012:test-bucket', + id: 'test-bucket', + bucketName: 'test-bucket', + }, + ], + lastUpdated: new Date().toISOString(), + }); + + const plan = await generateBucketPlan(mockContext, state, [testBucket]); + + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ + logicalId: 'buckets.test_bucket', + action: 'update', + resourceType: 'ALIYUN_OSS_BUCKET', + }); + }); + + it('should plan to delete bucket when removed from config', async () => { + mockOssOperations.getBucket.mockResolvedValue(null); + + const state = setResource(initialState, 'buckets.old_bucket', { + mode: 'managed', + region: 'cn-hangzhou', + definition: { + bucketName: 'old-bucket', + acl: 'private', + websiteConfiguration: {}, + storageClass: null, + domain: null, + }, + instances: [ + { + arn: 'arn:acs:oss:cn-hangzhou:123456789012:old-bucket', + id: 'old-bucket', + bucketName: 'old-bucket', + }, + ], + lastUpdated: new Date().toISOString(), + }); + + const plan = await generateBucketPlan(mockContext, state, []); + + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ + logicalId: 'buckets.old_bucket', + action: 'delete', + resourceType: 'ALIYUN_OSS_BUCKET', + }); + }); + + it('should plan to recreate bucket when state exists but remote is missing', async () => { + mockOssOperations.getBucket.mockResolvedValue(null); + + const state = setResource(initialState, 'buckets.test_bucket', { + mode: 'managed', + region: 'cn-hangzhou', + definition: { + bucketName: 'test-bucket', + acl: 'public-read', + websiteConfiguration: { + indexDocument: 'index.html', + errorDocument: '404.html', + }, + storageClass: null, + domain: null, + }, + instances: [ + { + arn: 'arn:acs:oss:cn-hangzhou:123456789012:test-bucket', + id: 'test-bucket', + bucketName: 'test-bucket', + }, + ], + lastUpdated: new Date().toISOString(), + }); + + const plan = await generateBucketPlan(mockContext, state, [testBucket]); + + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ + logicalId: 'buckets.test_bucket', + action: 'create', + resourceType: 'ALIYUN_OSS_BUCKET', + drifted: true, + }); + expect(plan.items[0].changes?.after).toBeDefined(); + }); + + it('should return empty plan when no buckets defined and state is empty', async () => { + const plan = await generateBucketPlan(mockContext, initialState, undefined); + + expect(plan.items).toHaveLength(0); + }); + + it('should return empty plan when empty array and state is empty', async () => { + const plan = await generateBucketPlan(mockContext, initialState, []); + + expect(plan.items).toHaveLength(0); + }); + }); +}); diff --git a/tests/stack/aliyunStack/ossTypes.test.ts b/tests/stack/aliyunStack/ossTypes.test.ts new file mode 100644 index 0000000..c3d9540 --- /dev/null +++ b/tests/stack/aliyunStack/ossTypes.test.ts @@ -0,0 +1,221 @@ +import { + bucketToOssBucketConfig, + extractOssBucketDefinition, +} from '../../../src/stack/aliyunStack/ossTypes'; +import { BucketDomain, BucketAccessEnum } from '../../../src/types'; +import { BucketACL } from '../../../src/stack/bucketTypes'; + +describe('OssTypes', () => { + describe('bucketToOssBucketConfig', () => { + it('should convert bucket domain to OSS bucket config with minimal fields', () => { + const bucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + }; + + const result = bucketToOssBucketConfig(bucket); + + expect(result.bucketName).toBe('test-bucket'); + }); + + it('should include ACL when security is defined', () => { + const bucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + security: { + acl: BucketAccessEnum.PUBLIC_READ, + force_delete: false, + }, + }; + + const result = bucketToOssBucketConfig(bucket); + + expect(result.bucketName).toBe('test-bucket'); + expect(result.acl).toBe(BucketACL.PUBLIC_READ); + }); + + it('should map all ACL types correctly', () => { + const testCases = [ + { acl: BucketAccessEnum.PRIVATE, expected: BucketACL.PRIVATE }, + { acl: BucketAccessEnum.PUBLIC_READ, expected: BucketACL.PUBLIC_READ }, + { acl: BucketAccessEnum.PUBLIC_READ_WRITE, expected: BucketACL.PUBLIC_READ_WRITE }, + ]; + + testCases.forEach(({ acl, expected }) => { + const bucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + security: { acl, force_delete: false }, + }; + + const result = bucketToOssBucketConfig(bucket); + + expect(result.acl).toBe(expected); + }); + }); + + it('should include website configuration when website is defined', () => { + const bucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + website: { + index: 'index.html', + code: 'dist', + error_page: '404.html', + error_code: 404, + }, + }; + + const result = bucketToOssBucketConfig(bucket); + + expect(result.bucketName).toBe('test-bucket'); + expect(result.websiteConfig).toEqual({ + indexDocument: 'index.html', + errorDocument: '404.html', + }); + }); + + it('should include both ACL and website configuration', () => { + const bucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + security: { + acl: BucketAccessEnum.PUBLIC_READ, + force_delete: false, + }, + website: { + index: 'index.html', + code: 'dist', + error_page: '404.html', + error_code: 404, + }, + }; + + const result = bucketToOssBucketConfig(bucket); + + expect(result.bucketName).toBe('test-bucket'); + expect(result.acl).toBe(BucketACL.PUBLIC_READ); + expect(result.websiteConfig).toEqual({ + indexDocument: 'index.html', + errorDocument: '404.html', + }); + }); + + it('should include storage class when defined', () => { + const bucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + storage: { + class: 'Standard', + }, + }; + + const result = bucketToOssBucketConfig(bucket); + + expect(result.bucketName).toBe('test-bucket'); + expect(result.storageClass).toBe('Standard'); + }); + + it('should include domain when defined in website', () => { + const bucket: BucketDomain = { + key: 'test_bucket', + name: 'test-bucket', + website: { + index: 'index.html', + code: 'dist', + error_page: '404.html', + error_code: 404, + domain: 'www.example.com', + }, + }; + + const result = bucketToOssBucketConfig(bucket); + + expect(result.domain).toBe('www.example.com'); + }); + }); + + describe('extractOssBucketDefinition', () => { + it('should extract all attributes with empty object for undefined non-primitive fields', () => { + const config = { + bucketName: 'test-bucket', + }; + + const definition = extractOssBucketDefinition(config); + + expect(definition).toEqual({ + bucketName: 'test-bucket', + acl: null, + websiteConfiguration: {}, + storageClass: null, + domain: null, + }); + }); + + it('should extract ACL when defined', () => { + const config = { + bucketName: 'test-bucket', + acl: BucketACL.PUBLIC_READ, + }; + + const definition = extractOssBucketDefinition(config); + + expect(definition.acl).toBe(BucketACL.PUBLIC_READ); + }); + + it('should extract website configuration', () => { + const config = { + bucketName: 'test-bucket', + websiteConfig: { + indexDocument: 'index.html', + errorDocument: '404.html', + }, + }; + + const definition = extractOssBucketDefinition(config); + + expect(definition.websiteConfiguration).toEqual({ + indexDocument: 'index.html', + errorDocument: '404.html', + }); + }); + + it('should set errorDocument to null when not provided', () => { + const config = { + bucketName: 'test-bucket', + websiteConfig: { + indexDocument: 'index.html', + }, + }; + + const definition = extractOssBucketDefinition(config); + + expect(definition.websiteConfiguration).toEqual({ + indexDocument: 'index.html', + errorDocument: null, + }); + }); + + it('should extract storage class when defined', () => { + const config = { + bucketName: 'test-bucket', + storageClass: 'Standard', + }; + + const definition = extractOssBucketDefinition(config); + + expect(definition.storageClass).toBe('Standard'); + }); + + it('should extract domain when defined', () => { + const config = { + bucketName: 'test-bucket', + domain: 'www.example.com', + }; + + const definition = extractOssBucketDefinition(config); + + expect(definition.domain).toBe('www.example.com'); + }); + }); +});