diff --git a/bin/clover/AGENTS.md b/bin/clover/AGENTS.md index f1a23f1d16..b07a57ead4 100644 --- a/bin/clover/AGENTS.md +++ b/bin/clover/AGENTS.md @@ -36,3 +36,30 @@ buck2 run //bin/si:si schema upload --from-file [filepath] ``` This will upload the spec files to make them available as schemas in System Initiative. Schemas that match components that already exist will upload as Working Copies. Existing components will need to be upgraded to take on any changes. + +## Testing and Debugging + +### Debugging Failed Actions + +When an action fails (Create, Update, Delete, Discover, Import, etc.), **always check the func run logs** to understand the actual error: + +1. Use `action-list` to find the failed action and its `funcRunId` +2. Use `func-run-get` with `logs: true` and optionally `result: true` to see: + - The actual error message from the cloud provider + - The function execution logs showing what happened + - The result/output of the function + +Example: +``` +# List actions to find the funcRunId +action-list --changeSetId + +# Get the func run details with logs +func-run-get --funcRunId --logs true --result true +``` + +**Important:** MCP tool errors (like schema validation errors) may hide the actual cloud provider error. Always dig into the func run logs to find the root cause. + +### Tests to Run + +Look in each pipeline for a smoke.md file that describes which tests to run for that provider. diff --git a/bin/clover/src/pipelines/gcp/funcs/actions/create.ts b/bin/clover/src/pipelines/gcp/funcs/actions/create.ts index 042a1c5f4e..dd669e18b5 100644 --- a/bin/clover/src/pipelines/gcp/funcs/actions/create.ts +++ b/bin/clover/src/pipelines/gcp/funcs/actions/create.ts @@ -93,9 +93,11 @@ ${errorText}` // - Compute Engine uses "kind" containing "operation" // - GKE/Container API uses "operationType" field // - Other APIs (API Keys, etc.) use "name" starting with "operations/" + // - Cloud Run v2 uses "name" containing "/operations/" and has "metadata" with resource details const isLRO = (responseJson.kind && responseJson.kind.includes("operation")) || responseJson.operationType || - (responseJson.name && responseJson.name.startsWith("operations/")); + (responseJson.name && responseJson.name.startsWith("operations/")) || + (responseJson.name && responseJson.name.includes("/operations/") && responseJson.metadata); if (isLRO) { console.log(`[CREATE] LRO detected, polling for completion...`); diff --git a/bin/clover/src/pipelines/gcp/funcs/actions/refresh.ts b/bin/clover/src/pipelines/gcp/funcs/actions/refresh.ts index 017b0bac10..84cb53d53b 100644 --- a/bin/clover/src/pipelines/gcp/funcs/actions/refresh.ts +++ b/bin/clover/src/pipelines/gcp/funcs/actions/refresh.ts @@ -60,10 +60,14 @@ async function refreshViaList( let listUrl = buildUrlWithParams(baseUrl, listApiPath, component, projectId, { forList: true }); // Handle parent as query parameter for some APIs + // Only add parent if it's declared as a valid query parameter in the API if (!listUrl.includes("parent=") && !listApiPath.path.includes("{parent}") && !listApiPath.path.includes("{+parent}")) { - const parentValue = resolveParamValue(component, "parent", projectId, true); - if (parentValue) { - listUrl += (listUrl.includes("?") ? "&" : "?") + `parent=${encodeURIComponent(parentValue)}`; + const validQueryParams = listApiPath.queryParams || []; + if (validQueryParams.includes("parent")) { + const parentValue = resolveParamValue(component, "parent", projectId, true); + if (parentValue) { + listUrl += (listUrl.includes("?") ? "&" : "?") + `parent=${encodeURIComponent(parentValue)}`; + } } } @@ -95,7 +99,7 @@ async function refreshViaList( for (const resource of items || []) { const resourceName = resource.name || resource.id; if (resourceName === resourceId || resourceName === domainName || - resourceName?.endsWith(`/${resourceId}`) || resourceId.endsWith(`/${resourceName}`)) { + resourceName?.endsWith(`/${resourceId}`) || resourceId.endsWith(`/${resourceName}`)) { return { payload: normalizeGcpResourceValues(resource), status: "ok" }; } } diff --git a/bin/clover/src/pipelines/gcp/funcs/actions/update.ts b/bin/clover/src/pipelines/gcp/funcs/actions/update.ts index 5aea401d23..ea2421192a 100644 --- a/bin/clover/src/pipelines/gcp/funcs/actions/update.ts +++ b/bin/clover/src/pipelines/gcp/funcs/actions/update.ts @@ -34,6 +34,12 @@ async function main(component: Input): Promise { if (currentResource.fingerprint) { updatePayload.fingerprint = currentResource.fingerprint; } + + // Some assets requires 'name' in the request body even for updates + // Always include it from the current resource if available + if (currentResource.name && !updatePayload.name) { + updatePayload.name = currentResource.name; + } } // Try to get update API path first, fall back to patch @@ -83,12 +89,23 @@ async function main(component: Input): Promise { // Many GCP APIs require or benefit from an updateMask query parameter // that specifies which fields are being updated - const updateFields = Object.keys(updatePayload).filter(k => k !== 'fingerprint'); + // Exclude 'fingerprint' (used for concurrency) and 'name' (required in body but immutable) + const updateFields = Object.keys(updatePayload).filter(k => k !== 'fingerprint' && k !== 'name'); if (updateFields.length > 0) { const updateMask = updateFields.join(','); url += (url.includes('?') ? '&' : '?') + `updateMask=${encodeURIComponent(updateMask)}`; } + // Some GCP APIs (like Pub/Sub) use a wrapper request type (e.g., UpdateTopicRequest) + // where the payload needs to be wrapped under a specific field (e.g., "topic") + // If requestWrapperField is set, wrap the payload accordingly + let requestBody = updatePayload; + if (updateApiPath.requestWrapperField) { + requestBody = { + [updateApiPath.requestWrapperField]: updatePayload, + }; + } + // Make the API request with retry logic const response = await siExec.withRetry(async () => { const resp = await fetch(url, { @@ -97,7 +114,7 @@ async function main(component: Input): Promise { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", }, - body: JSON.stringify(updatePayload), + body: JSON.stringify(requestBody), }); if (!resp.ok) { diff --git a/bin/clover/src/pipelines/gcp/funcs/management/discover.ts b/bin/clover/src/pipelines/gcp/funcs/management/discover.ts index 4325426020..0106ebaa1c 100644 --- a/bin/clover/src/pipelines/gcp/funcs/management/discover.ts +++ b/bin/clover/src/pipelines/gcp/funcs/management/discover.ts @@ -70,10 +70,14 @@ async function main({ thisComponent }: Input): Promise { // Handle parent as query parameter for APIs like Resource Manager Folders // that don't use parent in the path but require it as a query parameter + // Only add parent if it's declared as a valid query parameter in the API if (!listUrl.includes("parent=") && !listApiPath.path.includes("{parent}") && !listApiPath.path.includes("{+parent}")) { - const parentValue = resolveParamValue(component, "parent", projectId, true); - if (parentValue) { - listUrl += (listUrl.includes("?") ? "&" : "?") + `parent=${encodeURIComponent(parentValue)}`; + const validQueryParams = listApiPath.queryParams || []; + if (validQueryParams.includes("parent")) { + const parentValue = resolveParamValue(component, "parent", projectId, true); + if (parentValue) { + listUrl += (listUrl.includes("?") ? "&" : "?") + `parent=${encodeURIComponent(parentValue)}`; + } } } @@ -148,7 +152,13 @@ async function main({ thisComponent }: Input): Promise { } else { // Try to get from discovered resource first, then fall back to domain paramValue = _.get(resource, [paramName]) || - _.get(component.properties, ["domain", paramName]); + _.get(component.properties, ["domain", paramName]); + + // GCP often returns full URLs for reference fields - extract just the resource name + if (paramValue && typeof paramValue === "string" && paramValue.startsWith("https://")) { + const urlParts = paramValue.split("/"); + paramValue = urlParts[urlParts.length - 1]; + } } if (paramValue) { @@ -174,15 +184,18 @@ async function main({ thisComponent }: Input): Promise { const fullResource = await resourceResponse.json(); + // Normalize the resource to convert full GCP URLs to simple values + const normalizedResource = normalizeGcpResourceValues(fullResource); + const properties = { si: { resourceId, }, domain: { ...component.properties?.domain || {}, - ...fullResource, + ...normalizedResource, }, - resource: fullResource, + resource: normalizedResource, }; // Apply refinement filter diff --git a/bin/clover/src/pipelines/gcp/funcs/shared/utils.ts b/bin/clover/src/pipelines/gcp/funcs/shared/utils.ts index 65d36cab24..fa3713d716 100644 --- a/bin/clover/src/pipelines/gcp/funcs/shared/utils.ts +++ b/bin/clover/src/pipelines/gcp/funcs/shared/utils.ts @@ -157,7 +157,7 @@ function isFullResourcePath(resourceId: string, pathTemplate: string): boolean { function buildUrlWithParams( baseUrl: string, - apiPath: { path: string; parameterOrder?: string[] }, + apiPath: { path: string; parameterOrder?: string[]; queryParams?: string[] }, component: Input, projectId: string | undefined, options: { resourceId?: string; forList?: boolean } = {} @@ -187,6 +187,10 @@ function buildUrlWithParams( if (paramValue) { // Handle {+param} (reserved expansion - don't encode, allows slashes) if (url.includes(`{+${paramName}}`)) { + // For {+project}, if paramValue doesn't start with "projects/", prepend it + if ((paramName === "project" || paramName === "projectId") && !paramValue.startsWith("projects/")) { + paramValue = `projects/${paramValue}`; + } url = url.replace(`{+${paramName}}`, paramValue); } else if (url.includes(`{${paramName}}`)) { // Handle {param} (simple expansion - encode) @@ -197,13 +201,24 @@ function buildUrlWithParams( } } } + } - // Add query parameters if any - if (queryParams.length > 0) { - url += `?${queryParams.join("&")}`; + // Handle additional query parameters (e.g., serviceId for Cloud Run) + // These are parameters defined with location: "query" in the API spec + if (apiPath.queryParams) { + for (const paramName of apiPath.queryParams) { + const paramValue = resolveParamValue(component, paramName, projectId, options.forList); + if (paramValue) { + queryParams.push(`${paramName}=${encodeURIComponent(paramValue)}`); + } } } + // Add query parameters if any + if (queryParams.length > 0) { + url += `?${queryParams.join("&")}`; + } + // Fail fast if URL still contains unresolved placeholders // This catches cases where required parameters (like location in parent) are missing const unresolvedMatches = [...url.matchAll(/\{[+]?([^}]+)\}/g)]; diff --git a/bin/clover/src/pipelines/gcp/pipeline-steps/addDefaultProps.ts b/bin/clover/src/pipelines/gcp/pipeline-steps/addDefaultProps.ts index 50488a587d..2c5e7bae5b 100644 --- a/bin/clover/src/pipelines/gcp/pipeline-steps/addDefaultProps.ts +++ b/bin/clover/src/pipelines/gcp/pipeline-steps/addDefaultProps.ts @@ -76,10 +76,27 @@ export function addDefaultProps(specs: ExpandedPkgSpec[]): ExpandedPkgSpec[] { false, ); insertProp.data.hidden = true; + + // Collect query parameters for the insert method + // These are parameters with location: "query" that need to be added to the URL + const queryParams: string[] = []; + if (gcpSchema.methods.insert.parameters) { + for ( + const [paramName, paramDef] of Object.entries( + gcpSchema.methods.insert.parameters, + ) + ) { + if (paramDef.location === "query") { + queryParams.push(paramName); + } + } + } + insertProp.data.defaultValue = JSON.stringify({ path: gcpSchema.methods.insert.path, httpMethod: gcpSchema.methods.insert.httpMethod, parameterOrder: gcpSchema.methods.insert.parameterOrder, + ...(queryParams.length > 0 ? { queryParams } : {}), }); extraProp.entries.push(insertProp); } @@ -108,10 +125,25 @@ export function addDefaultProps(specs: ExpandedPkgSpec[]): ExpandedPkgSpec[] { false, ); patchProp.data.hidden = true; + + // Detect if the patch request uses a wrapper type (e.g., UpdateTopicRequest) + // Wrapper types have a field that contains the resource (e.g., "topic") and an "updateMask" field + // If detected, store the wrapper field name so the update action can wrap the payload + let requestWrapperField: string | undefined; + const patchRequest = gcpSchema.methods.patch.request; + if (patchRequest?.properties) { + const props = Object.keys(patchRequest.properties); + // A wrapper type has exactly 2 properties: the resource wrapper field and updateMask + if (props.length === 2 && props.includes("updateMask")) { + requestWrapperField = props.find((p) => p !== "updateMask"); + } + } + patchProp.data.defaultValue = JSON.stringify({ path: gcpSchema.methods.patch.path, httpMethod: gcpSchema.methods.patch.httpMethod, parameterOrder: gcpSchema.methods.patch.parameterOrder, + ...(requestWrapperField ? { requestWrapperField } : {}), }); extraProp.entries.push(patchProp); } @@ -134,6 +166,20 @@ export function addDefaultProps(specs: ExpandedPkgSpec[]): ExpandedPkgSpec[] { } if (gcpSchema.methods.list) { + // Extract query parameters from the list method + const queryParams: string[] = []; + if (gcpSchema.methods.list.parameters) { + for ( + const [paramName, paramDef] of Object.entries( + gcpSchema.methods.list.parameters, + ) + ) { + if (paramDef.location === "query") { + queryParams.push(paramName); + } + } + } + const listProp = createScalarProp( "listApiPath", "string", @@ -144,6 +190,7 @@ export function addDefaultProps(specs: ExpandedPkgSpec[]): ExpandedPkgSpec[] { listProp.data.defaultValue = JSON.stringify({ path: gcpSchema.methods.list.path, parameterOrder: gcpSchema.methods.list.parameterOrder, + queryParams: queryParams, // Store valid query parameters }); extraProp.entries.push(listProp); } @@ -237,6 +284,21 @@ export function addDefaultProps(specs: ExpandedPkgSpec[]): ExpandedPkgSpec[] { pathParamsSet.add("location"); } + // Add query parameters from insert method to pathParameters + // These are URL params that should NOT be included in the request body + // (e.g., serviceId for Cloud Run services) + if (gcpSchema.methods.insert?.parameters) { + for ( + const [paramName, param] of Object.entries( + gcpSchema.methods.insert.parameters, + ) + ) { + if (param.location === "query") { + pathParamsSet.add(paramName); + } + } + } + const propUsageMap: PropUsageMap = { createOnly: [], updatable: [], diff --git a/bin/clover/src/pipelines/gcp/smoke.md b/bin/clover/src/pipelines/gcp/smoke.md new file mode 100644 index 0000000000..e159c855cf --- /dev/null +++ b/bin/clover/src/pipelines/gcp/smoke.md @@ -0,0 +1,328 @@ +# GCP Smoke Test + +This document provides instructions for testing Google Cloud Platform components in System Initiative. + +## Prerequisites + +### 1. Regenerate and Upload Specs + +Before running tests, regenerate the GCP specs and upload them: + +```bash +# Generate specs +deno task run generate-specs --provider="google cloud" + +# Upload specs (from the bin/clover directory) +# Create a single change set for all schema uploads +export UPLOAD_CS_ID=$(buck2 run //bin/si:si change-set create --name "GCP Schema Upload $(date -Iseconds)" | jq -r '.id') + +# Upload all schemas to the same change set +buck2 run //bin/si:si schema upload --from-file si-specs/Google\ Cloud\ Compute\ Engine\ Networks.json --change-set-id "$UPLOAD_CS_ID" +buck2 run //bin/si:si schema upload --from-file si-specs/Google\ Cloud\ Pub.Sub\ Topics.json --change-set-id "$UPLOAD_CS_ID" +buck2 run //bin/si:si schema upload --from-file si-specs/Google\ Cloud\ Compute\ Engine\ Disks.json --change-set-id "$UPLOAD_CS_ID" +buck2 run //bin/si:si schema upload --from-file si-specs/Google\ Cloud\ Compute\ Engine\ Addresses.json --change-set-id "$UPLOAD_CS_ID" +buck2 run //bin/si:si schema upload --from-file si-specs/Google\ Cloud\ API\ Keys\ Keys.json --change-set-id "$UPLOAD_CS_ID" + +# Apply the change set +buck2 run //bin/si:si change-set apply --change-set-id "$UPLOAD_CS_ID" +``` + +**Important**: Upload all schemas in a single change set, then apply it. This prevents cluttering the workspace with multiple open schema upload change sets. + +If components using these schemas already exist in the workspace, you'll need to upgrade them after applying the schema change set to use the new schema versions. + +### 2. Check for Credentials + +Check if a `Google Cloud Credential` component exists in the workspace. If not, ask the user to create one before proceeding. + +### 3. Gather Test Configuration + +Prompt the user for: +- **Naming prefix**: A unique prefix for test resources (default: `si-smoke-{timestamp}-`) +- **Zone**: GCP zone for zonal resources (default: `us-central1-a`) +- **Region**: GCP region for regional resources (default: `us-central1`) + +The project ID will be extracted from the credential's service account JSON. + +--- + +## Test Assets + +This smoke test covers 5 GCP assets that exercise different code paths: + +| # | Schema Name | Scope | API Pattern | Tests Discover | +|---|-------------|-------|-------------|----------------| +| 1 | Google Cloud Compute Engine Networks | Global | Simple `{param}` | Yes | +| 2 | Google Cloud Pub/Sub Topics | Project | Reserved `{+name}`, wrapper field | Yes | +| 3 | Google Cloud Compute Engine Disks | Zonal | Simple `{param}` with zone | Yes | +| 4 | Google Cloud Compute Engine Addresses | Regional | Simple `{param}` with region | Yes | +| 5 | Google Cloud API Keys Keys | Project | Reserved `{+parent}` | Yes | + +--- + +## Test Phases + +Run all tests in a single change set named "GCP Smoke Test - {timestamp}". + +### Phase 1: Create All Resources + +Create all 5 resources in the change set, then apply and wait for all Create actions to succeed. + +#### 1.1 Compute Engine Network (VPC) + +``` +Schema: Google Cloud Compute Engine Networks +Component Name: {prefix}vpc +Attributes: + /domain/name: {prefix}vpc + /domain/autoCreateSubnetworks: false + /secrets/Google Cloud Credential: subscription to credential component +``` + +#### 1.2 Pub/Sub Topic + +``` +Schema: Google Cloud Pub/Sub Topics +Component Name: {prefix}topic +Attributes: + /domain/name: projects/{project-id}/topics/{prefix}topic + /secrets/Google Cloud Credential: subscription to credential component +``` + +**Note**: The `/domain/name` must be the full resource path including `projects/{project-id}/topics/`. + +#### 1.3 Compute Engine Disk + +``` +Schema: Google Cloud Compute Engine Disks +Component Name: {prefix}disk +Attributes: + /domain/name: {prefix}disk + /domain/zone: {zone} + /domain/sizeGb: "10" + /domain/type: projects/{project-id}/zones/{zone}/diskTypes/pd-standard + /secrets/Google Cloud Credential: subscription to credential component +``` + +**Note**: The `/domain/type` must be the full URL path, not just `pd-standard`. + +#### 1.4 Compute Engine Address + +``` +Schema: Google Cloud Compute Engine Addresses +Component Name: {prefix}address +Attributes: + /domain/name: {prefix}address + /domain/region: {region} + /secrets/Google Cloud Credential: subscription to credential component +``` + +#### 1.5 API Key + +``` +Schema: Google Cloud API Keys Keys +Component Name: {prefix}apikey +Attributes: + /domain/displayName: {prefix}apikey + /secrets/Google Cloud Credential: subscription to credential component +``` + +After creating all components, apply the change set and verify all 5 Create actions succeed. + +--- + +### Phase 2: Update Resources + +Create a new change set for updates. Make minor changes to resources that support updates. + +**Note**: Only 3 of the 5 resources can be updated via the generic Update action. + +#### 2.1 Update Network + +``` +Update: /domain/routingConfig/routingMode: "GLOBAL" +``` + +#### 2.2 Update Pub/Sub Topic + +``` +Update: /domain/labels/smoke-test: "true" +``` + +#### 2.3 Disk + +**Skipped**: Compute Disks use a separate `setLabels` API method that isn't supported by the generic Update action. + +#### 2.4 Address + +**Skipped**: Compute Addresses do not have an Update action - they are immutable after creation. + +#### 2.5 Update API Key + +``` +Update: /domain/displayName: {prefix}apikey-updated +``` + +Apply the change set and verify all 3 Update actions succeed (Network, Topic, API Key). + +--- + +### Phase 3: Discover Resources + +Create a new change set for discovery. Test discover on all 5 schemas. + +#### 3.1 Discover Networks + +Run discover for `Google Cloud Compute Engine Networks`. Verify the test VPC is found among the discovered resources. + +#### 3.2 Discover Pub/Sub Topics + +Run discover for `Google Cloud Pub/Sub Topics`. Verify the test topic is found among the discovered resources. + +#### 3.3 Discover Disks + +Run discover for `Google Cloud Compute Engine Disks` with zone filter set to `{zone}`. Verify the test disk is found. + +#### 3.4 Discover Addresses + +Run discover for `Google Cloud Compute Engine Addresses` with region filter set to `{region}`. Verify the test address is found. + +#### 3.5 Discover API Keys + +Run discover for `Google Cloud API Keys Keys`. Verify the test API key is found among the discovered resources. + +Abandon this change set after verifying discovery works (don't merge discovered components). + +--- + +### Phase 4: Import Resources + +Create a new change set for import testing. Import each resource by its resource ID. + +For each resource: +1. Get the resource ID from the component's `/resource_value` or from the Create action result +2. Create a new component using component-import with that resource ID +3. Verify the imported component has the correct attributes + +#### Resource IDs + +| Resource | Resource ID Location | +|----------|---------------------| +| Network | Numeric ID (e.g., `6029756069415537918`) | +| Pub/Sub Topic | Full path (e.g., `projects/{project}/topics/{name}`) | +| Disk | Numeric ID (e.g., `7547182325923367760`) | +| Address | Numeric ID from resource | +| API Key | Full path (e.g., `projects/{project}/locations/global/keys/{uid}`) | + +#### Import Requirements + +**Important**: Zonal and regional resources require location attributes during import: + +- **Disk Import**: Must set `/domain/zone` attribute (e.g., `"us-central1-a"`) +- **Address Import**: Must set `/domain/region` attribute (e.g., `"us-central1"`) + +Without these attributes, the import will fail with "Cannot build API URL - unresolved parameters" error. + +Abandon this change set after verifying imports work. + +--- + +### Phase 5: Delete All Resources + +Create a new change set for deletion. Delete all 5 test components, apply, and verify all Destroy actions succeed. + +After successful deletion, verify only the credential component remains in HEAD. + +--- + +## Known Issues + +### Disk Type Requires Full URL + +The `/domain/type` attribute for Compute Engine Disks requires the full URL path: + +- **Wrong**: `pd-standard` +- **Correct**: `projects/{project}/zones/{zone}/diskTypes/pd-standard` + +The GCP API returns error 400 "Invalid value for field 'resource.type': The URL is malformed" if you use the short form. + +### Pub/Sub Topic Name Requires Full Path + +The `/domain/name` attribute for Pub/Sub Topics must include the full resource path: + +- **Wrong**: `my-topic` +- **Correct**: `projects/{project-id}/topics/my-topic` + +--- + +## Expected Errors + +### Success Indicators + +- Create/Update/Delete actions return `funcRunState: "Success"` with `status: "ok"` in the result +- Discover returns a message like "Discovered N resources" +- Import returns `message: "Imported Resource"` + +### Common Failure Patterns + +| Error | Likely Cause | +|-------|--------------| +| `404 Not Found` on refresh | Resource was deleted outside SI or never created | +| `400 Bad Request` with "URL is malformed" | Missing full URL path (see Known Issues) | +| `403 Forbidden` | Credential lacks required IAM permissions | +| `409 Conflict` | Resource already exists with that name | +| `429 Too Many Requests` | Rate limited - will auto-retry | +| Unresolved parameters in URL | Required property not set (zone, region, parent, etc.) | + +### Checking Failures + +When an action fails, use `func-run-get` with `logs: true` and `result: true` to see the actual error from the GCP API. + +--- + +## Test Results Template + +After running the smoke test, record results: + +``` +Date: +Credential: +Prefix Used: + +Phase 1 - Create: + [ ] Network: + [ ] Pub/Sub Topic: + [ ] Disk: + [ ] Address: + [ ] API Key: + +Phase 2 - Update: + [ ] Network: + [ ] Pub/Sub Topic: + [N/A] Disk: (uses separate setLabels API) + [N/A] Address: (immutable - no Update action) + [ ] API Key: + +Phase 3 - Discover: + [ ] Networks: + [ ] Pub/Sub Topics: + [ ] Disks: + [ ] Addresses: + [ ] API Keys: + +Phase 4 - Import: + [ ] Network: + [ ] Pub/Sub Topic: + [ ] Disk: + [ ] Address: + [ ] API Key: + +Phase 5 - Delete: + [ ] Network: + [ ] Pub/Sub Topic: + [ ] Disk: + [ ] Address: + [ ] API Key: + +Issues Found: +``` diff --git a/bin/clover/src/pipelines/gcp/spec.ts b/bin/clover/src/pipelines/gcp/spec.ts index 7660699b80..a3cfa492e6 100644 --- a/bin/clover/src/pipelines/gcp/spec.ts +++ b/bin/clover/src/pipelines/gcp/spec.ts @@ -378,6 +378,26 @@ function buildGcpResourceSpec( } } + // Add query parameters from insert method as top-level domain props + // These are needed for API calls (e.g., serviceId for Cloud Run) + if (insert?.parameters) { + for (const [paramName, param] of Object.entries(insert.parameters)) { + // Only add query parameters (not path params which are in parameterOrder) + if (param.location !== "query") continue; + // Skip validateOnly and similar optional params + if (paramName === "validateOnly") continue; + // Skip deprecated parameters + if (param.deprecated === true) continue; + // Skip if already exists + if (domainProperties[paramName]) continue; + + domainProperties[paramName] = { + type: param.type || "string", + description: param.description || `The ${paramName} for this resource`, + }; + } + } + // Remove read-only properties from domain const nonReadOnlyDomainProperties = Object.fromEntries( Object.entries(domainProperties).filter(([_, prop]) => @@ -387,10 +407,13 @@ function buildGcpResourceSpec( // Remove output-only properties from domain (they belong in resourceValue only) // GCP doesn't consistently mark these with readOnly, so we detect via description patterns + // BUT: Keep path parameters from insert.parameterOrder - these are required for URL building + // Even if marked "[Output Only]" in their description, they're needed as inputs const outputOnlyProps = new Set(detectOutputOnlyProperties(insert?.request)); + const insertPathParams = new Set(insert?.parameterOrder || []); const nonOutputOnlyDomainProperties = Object.fromEntries( Object.entries(nonReadOnlyDomainProperties).filter(([name]) => - !outputOnlyProps.has(name) + !outputOnlyProps.has(name) || insertPathParams.has(name) ), );