diff --git a/packages/sdk/src/cli/commands/apply/application.ts b/packages/sdk/src/cli/commands/apply/application.ts index 5dcd250a6..af900977e 100644 --- a/packages/sdk/src/cli/commands/apply/application.ts +++ b/packages/sdk/src/cli/commands/apply/application.ts @@ -7,7 +7,7 @@ import { } from "@tailor-proto/tailor/v1/application_resource_pb"; import { fetchAll, resolveStaticWebsiteUrls, type OperatorClient } from "@/cli/shared/client"; import { createChangeSet } from "./change-set"; -import { areNormalizedEqual } from "./compare"; +import { areNormalizedEqual, collectDiffLines } from "./compare"; import { buildMetaRequest } from "./label"; import type { ApplyPhase, PlanContext } from "@/cli/commands/apply/apply"; import type { Application } from "@/cli/services/application"; @@ -73,6 +73,7 @@ type CreateApplication = { type UpdateApplication = { name: string; + details?: string[]; request: MessageInitShape; metaRequest: MessageInitShape; }; @@ -86,7 +87,10 @@ type ComparableApplication = { authNamespace: string; authIdpConfigName: string; cors: string[]; - subgraphs: Array<{ serviceType: Subgraph_ServiceType; serviceNamespace: string }>; + subgraphs: Array<{ + serviceType: Subgraph_ServiceType; + serviceNamespace: string; + }>; allowedIpAddresses: string[]; disableIntrospection: boolean; disabled: boolean; @@ -209,14 +213,14 @@ export async function planApplication(context: PlanContext) { }, }); } - changeSet.print(); + changeSet.print({ detail: context.detailPlan }); return changeSet; } // Skip application create/update when there are no subgraphs // (e.g. deploying only static web hosting) if (application.subgraphs.length === 0) { - changeSet.print(); + changeSet.print({ detail: context.detailPlan }); return changeSet; } @@ -285,6 +289,7 @@ export async function planApplication(context: PlanContext) { } else { changeSet.updates.push({ name: application.name, + details: collectDiffLines(normalizeComparableExistingApplication(existing), desired), request, metaRequest, }); @@ -297,7 +302,7 @@ export async function planApplication(context: PlanContext) { }); } - changeSet.print(); + changeSet.print({ detail: context.detailPlan }); return changeSet; } diff --git a/packages/sdk/src/cli/commands/apply/apply.ts b/packages/sdk/src/cli/commands/apply/apply.ts index 2edb43f9c..d07bb0d68 100644 --- a/packages/sdk/src/cli/commands/apply/apply.ts +++ b/packages/sdk/src/cli/commands/apply/apply.ts @@ -49,6 +49,7 @@ export interface ApplyOptions { noSchemaCheck?: boolean; noCache?: boolean; cleanCache?: boolean; + detailPlan?: boolean; // NOTE(remiposo): Provide an option to run build-only for testing purposes. // This could potentially be exposed as a CLI option. buildOnly?: boolean; @@ -61,6 +62,7 @@ export interface PlanContext { forRemoval: boolean; config: LoadedConfig; noSchemaCheck?: boolean; + detailPlan?: boolean; } export type ApplyPhase = "create-update" | "delete" | "delete-resources" | "delete-services"; @@ -242,9 +244,16 @@ export async function apply(options?: ApplyOptions) { forRemoval: false, config, noSchemaCheck: options?.noSchemaCheck, + detailPlan: options?.detailPlan, }; const functionRegistry = await withSpan("plan.functionRegistry", () => - planFunctionRegistry(client, workspaceId, application.name, functionEntries), + planFunctionRegistry( + client, + workspaceId, + application.name, + functionEntries, + options?.detailPlan, + ), ); const unchangedWorkflowJobs = new Set( functionRegistry.changeSet.unchanged @@ -269,6 +278,7 @@ export async function apply(options?: ApplyOptions) { workflowService?.workflows ?? {}, workflowBuildResult?.mainJobDeps ?? {}, unchangedWorkflowJobs, + options?.detailPlan, ), ), withSpan("plan.secretManager", () => planSecretManager(ctx)), diff --git a/packages/sdk/src/cli/commands/apply/auth.ts b/packages/sdk/src/cli/commands/apply/auth.ts index 434b1dd40..76fde53a1 100644 --- a/packages/sdk/src/cli/commands/apply/auth.ts +++ b/packages/sdk/src/cli/commands/apply/auth.ts @@ -25,7 +25,12 @@ import { type AuthService } from "@/cli/services/auth/service"; import { fetchAll, resolveStaticWebsiteUrls, type OperatorClient } from "@/cli/shared/client"; import { OAuth2ClientSchema } from "@/parser/service/auth"; import { createChangeSet } from "./change-set"; -import { areNormalizedEqual, normalizeProtoConfig, normalizeStringArray } from "./compare"; +import { + areNormalizedEqual, + collectDiffLines, + normalizeProtoConfig, + normalizeStringArray, +} from "./compare"; import { authHookFunctionName } from "./function-registry"; import { idpClientSecretName, idpClientVaultName } from "./idp"; import { buildMetaRequest, sdkNameLabelKey, type WithLabel } from "./label"; @@ -289,15 +294,15 @@ export async function planAuth(context: PlanContext) { planSCIMResources(client, workspaceId, auths, deletedServices), ]); - serviceChangeSet.print(); - idpConfigChangeSet.print(); - userProfileConfigChangeSet.print(); - tenantConfigChangeSet.print(); - machineUserChangeSet.print(); - authHookChangeSet.print(); - oauth2ClientChangeSet.print(); - scimChangeSet.print(); - scimResourceChangeSet.print(); + serviceChangeSet.print({ detail: context.detailPlan }); + idpConfigChangeSet.print({ detail: context.detailPlan }); + userProfileConfigChangeSet.print({ detail: context.detailPlan }); + tenantConfigChangeSet.print({ detail: context.detailPlan }); + machineUserChangeSet.print({ detail: context.detailPlan }); + authHookChangeSet.print({ detail: context.detailPlan }); + oauth2ClientChangeSet.print({ detail: context.detailPlan }); + scimChangeSet.print({ detail: context.detailPlan }); + scimResourceChangeSet.print({ detail: context.detailPlan }); return { changeSet: { service: serviceChangeSet, @@ -324,6 +329,7 @@ type CreateService = { type UpdateService = { name: string; + details?: string[]; request: MessageInitShape; metaRequest: MessageInitShape; }; @@ -411,6 +417,14 @@ async function planServices( } else { changeSet.updates.push({ name: config.name, + details: collectDiffLines( + { + publishSessionEvents: existing.resource.publishSessionEvents, + }, + { + publishSessionEvents: config.publishSessionEvents ?? false, + }, + ), request, metaRequest, }); @@ -452,6 +466,7 @@ type CreateIdPConfig = { type UpdateIdPConfig = { name: string; + details?: string[]; idpConfig: Readonly; request: MessageInitShape; }; @@ -521,11 +536,43 @@ async function planIdPConfigs( existingMap.delete(idpConfig.name); continue; } + const existingComparable = normalizeProtoConfig({ + name: existing.name, + authType: existing.authType, + config: + existing.config?.config.case === "oidc" + ? { + config: { + case: "oidc", + value: { + ...existing.config.config.value, + issuerUrl: existing.config.config.value.issuerUrl || undefined, + }, + }, + } + : existing.config, + }); + const desiredComparableNormalized = normalizeProtoConfig({ + ...desiredComparable, + config: + desiredComparable.config?.config?.case === "oidc" + ? { + config: { + case: "oidc", + value: { + ...desiredComparable.config.config.value, + issuerUrl: desiredComparable.config.config.value.issuerUrl || undefined, + }, + }, + } + : desiredComparable.config, + }); if (areAuthIdPConfigsEqual(existing, desiredComparable)) { changeSet.unchanged.push({ name: idpConfig.name }); } else { changeSet.updates.push({ name: idpConfig.name, + details: collectDiffLines(existingComparable, desiredComparableNormalized), idpConfig, request: { workspaceId, @@ -1166,7 +1213,9 @@ function normalizeComparableUserProfileConfig( | MessageInitShape | { providerType?: UserProfileProviderConfig_UserProfileProviderType; - config?: { config?: { case?: string; value?: Record } }; + config?: { + config?: { case?: string; value?: Record }; + }; }, ) { const comparableConfig = config.config?.config; @@ -1211,7 +1260,9 @@ function normalizeComparableTenantProviderConfig( | undefined | { providerType?: TenantProviderConfig_TenantProviderType; - config?: { config?: { case?: string; value?: Record } }; + config?: { + config?: { case?: string; value?: Record }; + }; }, ) { return normalizeProtoConfig(config); @@ -1223,7 +1274,9 @@ function areTenantProviderConfigsEqual( | undefined | { providerType?: TenantProviderConfig_TenantProviderType; - config?: { config?: { case?: string; value?: Record } }; + config?: { + config?: { case?: string; value?: Record }; + }; }, desired: MessageInitShape, ) { diff --git a/packages/sdk/src/cli/commands/apply/change-set.test.ts b/packages/sdk/src/cli/commands/apply/change-set.test.ts index 59a37c910..b5c79c8c6 100644 --- a/packages/sdk/src/cli/commands/apply/change-set.test.ts +++ b/packages/sdk/src/cli/commands/apply/change-set.test.ts @@ -1,7 +1,23 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { createChangeSet, formatPlanSummary, summarizeChangeSets } from "./change-set"; import type { HasName } from "./change-set"; +vi.mock("@/cli/shared/logger", () => ({ + logger: { + log: vi.fn(), + info: vi.fn(), + }, + styles: { + bold: (value: string) => value, + }, + symbols: { + create: "+", + update: "~", + delete: "-", + replace: "+/-", + }, +})); + function createNamedChangeSet(title: string) { return createChangeSet(title); } @@ -58,3 +74,19 @@ describe("formatPlanSummary", () => { ).toBe("Plan: 1 to create, 2 to update, 0 to delete, 3 to replace, 15 unchanged"); }); }); + +describe("ChangeSet.print", () => { + test("prints detail lines only when detail output is enabled", async () => { + const { logger } = await import("@/cli/shared/logger"); + const changeSet = createNamedChangeSet("Applications"); + changeSet.updates.push({ name: "app-a", details: ['cors[0]: remote="a" local="b"'] }); + + changeSet.print(); + expect(logger.info).not.toHaveBeenCalled(); + + changeSet.print({ detail: true }); + expect(logger.info).toHaveBeenCalledWith(' cors[0]: remote="a" local="b"', { + mode: "plain", + }); + }); +}); diff --git a/packages/sdk/src/cli/commands/apply/change-set.ts b/packages/sdk/src/cli/commands/apply/change-set.ts index 5caf3aec0..de9dbde9e 100644 --- a/packages/sdk/src/cli/commands/apply/change-set.ts +++ b/packages/sdk/src/cli/commands/apply/change-set.ts @@ -2,6 +2,7 @@ import { logger, styles, symbols } from "@/cli/shared/logger"; export interface HasName { name: string; + details?: string[]; } export type ChangeSet< @@ -17,7 +18,7 @@ export type ChangeSet< readonly replaces: R[]; readonly unchanged: HasName[]; isEmpty: () => boolean; - print: () => void; + print: (options?: { detail?: boolean }) => void; }; export interface PlanSummary { @@ -56,15 +57,32 @@ export function createChangeSet< replaces, unchanged, isEmpty, - print: () => { + print: (options) => { if (isEmpty()) { return; } logger.log(styles.bold(`${title}:`)); - creates.forEach((item) => logger.log(` ${symbols.create} ${item.name}`)); - deletes.forEach((item) => logger.log(` ${symbols.delete} ${item.name}`)); - updates.forEach((item) => logger.log(` ${symbols.update} ${item.name}`)); - replaces.forEach((item) => logger.log(` ${symbols.replace} ${item.name}`)); + const printItem = (symbol: string, item: HasName, fallback: string[]) => { + logger.log(` ${symbol} ${item.name}`); + if (!options?.detail) { + return; + } + for (const detail of item.details ?? fallback) { + logger.info(` ${detail}`, { mode: "plain" }); + } + }; + creates.forEach((item) => + printItem(symbols.create, item, ["resource: remote=missing local=present"]), + ); + deletes.forEach((item) => + printItem(symbols.delete, item, ["resource: remote=present local=missing"]), + ); + updates.forEach((item) => + printItem(symbols.update, item, ["resource: remote and local differ"]), + ); + replaces.forEach((item) => + printItem(symbols.replace, item, ["resource: replacement required"]), + ); }, }; } diff --git a/packages/sdk/src/cli/commands/apply/compare.test.ts b/packages/sdk/src/cli/commands/apply/compare.test.ts new file mode 100644 index 000000000..297d2a1b3 --- /dev/null +++ b/packages/sdk/src/cli/commands/apply/compare.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; +import { collectDiffLines } from "./compare"; + +describe("collectDiffLines", () => { + test("collects field-path diffs for nested objects", () => { + expect( + collectDiffLines( + { + cors: ["https://a.example.com"], + nested: { enabled: false }, + }, + { + cors: ["https://b.example.com"], + nested: { enabled: true }, + }, + ), + ).toEqual([ + 'cors[0]: remote="https://a.example.com" local="https://b.example.com"', + "nested.enabled: remote=false local=true", + ]); + }); + + test("truncates diff output when the line limit is reached", () => { + expect(collectDiffLines({ a: 1, b: 2, c: 3 }, { a: 4, b: 5, c: 6 }, 2)).toEqual([ + "a: remote=1 local=4", + "b: remote=2 local=5", + "...diff output truncated", + ]); + }); + + test("collapses missing object subtrees to the parent path", () => { + expect( + collectDiffLines( + { + schema: { + fields: { + description: { + type: "string", + required: false, + }, + }, + }, + }, + { + schema: { + fields: {}, + }, + }, + ), + ).toEqual(["schema.fields.description: remote=present local=missing"]); + }); +}); diff --git a/packages/sdk/src/cli/commands/apply/compare.ts b/packages/sdk/src/cli/commands/apply/compare.ts index 1eb3acfe6..65f864840 100644 --- a/packages/sdk/src/cli/commands/apply/compare.ts +++ b/packages/sdk/src/cli/commands/apply/compare.ts @@ -51,3 +51,100 @@ export function areNormalizedEqual(left: unknown, right: unknown): boolean { stableStringify(normalizeProtoConfig(left)) === stableStringify(normalizeProtoConfig(right)) ); } + +const DEFAULT_DIFF_LINE_LIMIT = 40; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatDiffValue(value: unknown): string { + if (value === undefined) { + return "undefined"; + } + if (value === null) { + return "null"; + } + return stableStringify(value); +} + +function describePresence(value: unknown): "present" | "missing" { + return value === undefined || value === null ? "missing" : "present"; +} + +/** + * Collect field-path diff lines between two normalized values. + * @param left - Remote/current value + * @param right - Desired/local value + * @param maxLines - Maximum number of diff lines to emit + * @returns Diff lines + */ +export function collectDiffLines( + left: unknown, + right: unknown, + maxLines = DEFAULT_DIFF_LINE_LIMIT, +): string[] { + const lines: string[] = []; + const normalizedLeft = normalizeProtoConfig(left); + const normalizedRight = normalizeProtoConfig(right); + + const walk = (leftValue: unknown, rightValue: unknown, path: string) => { + if (lines.length >= maxLines) { + return; + } + + if (stableStringify(leftValue) === stableStringify(rightValue)) { + return; + } + + if ( + ((leftValue === undefined || leftValue === null) && + (Array.isArray(rightValue) || isPlainObject(rightValue))) || + ((rightValue === undefined || rightValue === null) && + (Array.isArray(leftValue) || isPlainObject(leftValue))) + ) { + lines.push( + `${path}: remote=${describePresence(leftValue)} local=${describePresence(rightValue)}`, + ); + return; + } + + if (Array.isArray(leftValue) || Array.isArray(rightValue)) { + const leftArray = Array.isArray(leftValue) ? leftValue : []; + const rightArray = Array.isArray(rightValue) ? rightValue : []; + const maxLength = Math.max(leftArray.length, rightArray.length); + for (let index = 0; index < maxLength; index += 1) { + walk(leftArray[index], rightArray[index], `${path}[${index}]`); + if (lines.length >= maxLines) { + return; + } + } + return; + } + + if (isPlainObject(leftValue) || isPlainObject(rightValue)) { + const leftObject = isPlainObject(leftValue) ? leftValue : {}; + const rightObject = isPlainObject(rightValue) ? rightValue : {}; + const keys = new Set([...Object.keys(leftObject), ...Object.keys(rightObject)]); + for (const key of [...keys].sort()) { + walk(leftObject[key], rightObject[key], path === "$" ? key : `${path}.${key}`); + if (lines.length >= maxLines) { + return; + } + } + return; + } + + lines.push( + `${path}: remote=${formatDiffValue(leftValue)} local=${formatDiffValue(rightValue)}`, + ); + }; + + walk(normalizedLeft, normalizedRight, "$"); + + if (lines.length >= maxLines) { + return [...lines.slice(0, maxLines), "...diff output truncated"]; + } + + return lines; +} diff --git a/packages/sdk/src/cli/commands/apply/function-registry.ts b/packages/sdk/src/cli/commands/apply/function-registry.ts index 8b6424a04..b5d92b8f1 100644 --- a/packages/sdk/src/cli/commands/apply/function-registry.ts +++ b/packages/sdk/src/cli/commands/apply/function-registry.ts @@ -3,6 +3,7 @@ import { Code, ConnectError } from "@connectrpc/connect"; import { fetchAll, type OperatorClient } from "@/cli/shared/client"; import { logger } from "@/cli/shared/logger"; import { createChangeSet } from "./change-set"; +import { collectDiffLines } from "./compare"; import { buildMetaRequest, sdkNameLabelKey, type WithLabel } from "./label"; import type { OwnerConflict, UnmanagedResource } from "./confirm"; import type { ApplyPhase } from "@/cli/commands/apply/apply"; @@ -32,6 +33,7 @@ type CreateFunction = { type UpdateFunction = { name: string; + details?: string[]; entry: FunctionEntry; metaRequest: MessageInitShape; }; @@ -217,6 +219,7 @@ type ExistingFunction = { * @param workspaceId - Workspace ID * @param appName - Application name * @param entries - Desired function entries + * @param detailPlan - Whether to include detailed plan information * @returns Planned changes */ export async function planFunctionRegistry( @@ -224,6 +227,7 @@ export async function planFunctionRegistry( workspaceId: string, appName: string, entries: FunctionEntry[], + detailPlan = false, ) { const changeSet = createChangeSet( "Function registry", @@ -301,6 +305,10 @@ export async function planFunctionRegistry( } else { changeSet.updates.push({ name: entry.name, + details: collectDiffLines( + { contentHash: existing.resource.contentHash }, + { contentHash: entry.contentHash }, + ), entry, metaRequest, }); @@ -331,7 +339,7 @@ export async function planFunctionRegistry( } } - changeSet.print(); + changeSet.print({ detail: detailPlan }); return { changeSet, conflicts, unmanaged, resourceOwners }; } diff --git a/packages/sdk/src/cli/commands/apply/idp.ts b/packages/sdk/src/cli/commands/apply/idp.ts index 2f518c9df..6ec29e856 100644 --- a/packages/sdk/src/cli/commands/apply/idp.ts +++ b/packages/sdk/src/cli/commands/apply/idp.ts @@ -13,7 +13,7 @@ import { } from "@tailor-proto/tailor/v1/idp_resource_pb"; import { fetchAll, type OperatorClient } from "@/cli/shared/client"; import { createChangeSet } from "./change-set"; -import { areNormalizedEqual } from "./compare"; +import { areNormalizedEqual, collectDiffLines } from "./compare"; import { buildMetaRequest, sdkNameLabelKey, type WithLabel } from "./label"; import type { OwnerConflict, UnmanagedResource } from "./confirm"; import type { ApplyPhase, PlanContext } from "@/cli/commands/apply/apply"; @@ -156,8 +156,8 @@ export async function planIdP(context: PlanContext) { const deletedServices = serviceChangeSet.deletes.map((del) => del.name); const clientChangeSet = await planClients(client, workspaceId, idps, deletedServices); - serviceChangeSet.print(); - clientChangeSet.print(); + serviceChangeSet.print({ detail: context.detailPlan }); + clientChangeSet.print({ detail: context.detailPlan }); return { changeSet: { service: serviceChangeSet, @@ -177,6 +177,7 @@ type CreateService = { type UpdateService = { name: string; + details?: string[]; request: MessageInitShape; metaRequest: MessageInitShape; }; @@ -361,11 +362,21 @@ async function planServices( currentOwner: existing.label, }); } + const current = normalizeComparableIdPService({ + authorization: existing.resource.authorization, + lang: existing.resource.lang, + userAuthPolicy: normalizeComparableUserAuthPolicy(existing.resource.userAuthPolicy), + publishUserEvents: existing.resource.publishUserEvents, + disableGqlOperations: normalizeComparableDisableGqlOperations( + existing.resource.disableGqlOperations, + ), + }); if (isManagedByApp && areIdPServicesEqual(existing.resource, desired)) { changeSet.unchanged.push({ name: namespaceName }); } else { changeSet.updates.push({ name: namespaceName, + details: collectDiffLines(current, desired), request, metaRequest, }); diff --git a/packages/sdk/src/cli/commands/apply/index.ts b/packages/sdk/src/cli/commands/apply/index.ts index 7f5d5f16c..2bea6c101 100644 --- a/packages/sdk/src/cli/commands/apply/index.ts +++ b/packages/sdk/src/cli/commands/apply/index.ts @@ -24,6 +24,9 @@ export const applyCommand = defineAppCommand({ "clean-cache": arg(z.boolean().optional(), { description: "Clean the bundle cache before building", }), + "detail-plan": arg(z.boolean().optional(), { + description: "Show detailed reasons for each planned action", + }), }) .strict(), run: async (args) => { @@ -38,6 +41,7 @@ export const applyCommand = defineAppCommand({ noSchemaCheck: args["no-schema-check"], noCache: args["no-cache"], cleanCache: args["clean-cache"], + detailPlan: args["detail-plan"], }); }, }); diff --git a/packages/sdk/src/cli/commands/apply/secret-manager.ts b/packages/sdk/src/cli/commands/apply/secret-manager.ts index 828c92f00..79d44ba2d 100644 --- a/packages/sdk/src/cli/commands/apply/secret-manager.ts +++ b/packages/sdk/src/cli/commands/apply/secret-manager.ts @@ -1,6 +1,7 @@ import { Code, ConnectError } from "@connectrpc/connect"; import { fetchAll, type OperatorClient } from "@/cli/shared/client"; import { createChangeSet } from "./change-set"; +import { collectDiffLines } from "./compare"; import { buildMetaRequest, sdkNameLabelKey, type WithLabel } from "./label"; import { hashValue, loadSecretsState, saveSecretsState } from "./secrets-state"; import type { OwnerConflict, UnmanagedResource } from "./confirm"; @@ -14,6 +15,7 @@ type CreateVault = { type ExistingVault = { name: string; + details?: string[]; workspaceId: string; }; @@ -32,6 +34,7 @@ type CreateSecret = { type UpdateSecret = { name: string; + details?: string[]; secretName: string; workspaceId: string; vaultName: string; @@ -127,6 +130,7 @@ export async function planSecretManager(context: PlanContext) { } else { vaultChangeSet.updates.push({ name: vaultName, + details: collectDiffLines({ name: existing.resource.name }, { name: vaultName }), workspaceId, }); } @@ -170,6 +174,7 @@ export async function planSecretManager(context: PlanContext) { if (currentHash !== storedHash) { secretChangeSet.updates.push({ name: `${vaultName}/${secret.name}`, + details: collectDiffLines({ hash: storedHash ?? "unknown" }, { hash: currentHash }), secretName: secret.name, workspaceId, vaultName, @@ -241,8 +246,8 @@ export async function planSecretManager(context: PlanContext) { } } - vaultChangeSet.print(); - secretChangeSet.print(); + vaultChangeSet.print({ detail: context.detailPlan }); + secretChangeSet.print({ detail: context.detailPlan }); return { vaultChangeSet, secretChangeSet, conflicts, unmanaged, resourceOwners }; } diff --git a/packages/sdk/src/cli/commands/apply/staticwebsite.ts b/packages/sdk/src/cli/commands/apply/staticwebsite.ts index ad645d689..d9fa3cf16 100644 --- a/packages/sdk/src/cli/commands/apply/staticwebsite.ts +++ b/packages/sdk/src/cli/commands/apply/staticwebsite.ts @@ -7,7 +7,7 @@ import { } from "@tailor-proto/tailor/v1/staticwebsite_pb"; import { fetchAll, type OperatorClient } from "@/cli/shared/client"; import { createChangeSet } from "./change-set"; -import { areNormalizedEqual } from "./compare"; +import { areNormalizedEqual, collectDiffLines } from "./compare"; import { buildMetaRequest, sdkNameLabelKey, type WithLabel } from "./label"; import type { OwnerConflict, UnmanagedResource } from "./confirm"; import type { ApplyPhase, PlanContext } from "@/cli/commands/apply/apply"; @@ -54,6 +54,7 @@ type CreateStaticWebsite = { type UpdateStaticWebsite = { name: string; + details?: string[]; request: MessageInitShape; metaRequest: MessageInitShape; }; @@ -185,8 +186,10 @@ export async function planStaticWebsite(context: PlanContext) { ) { changeSet.unchanged.push({ name }); } else { + const current = normalizeComparableStaticWebsite(existing.resource as ProtoStaticWebsite); changeSet.updates.push({ name, + details: collectDiffLines(current, desired), request, metaRequest, }); @@ -217,6 +220,6 @@ export async function planStaticWebsite(context: PlanContext) { } }); - changeSet.print(); + changeSet.print({ detail: context.detailPlan }); return { changeSet, conflicts, unmanaged, resourceOwners }; } diff --git a/packages/sdk/src/cli/commands/apply/tailordb/index.ts b/packages/sdk/src/cli/commands/apply/tailordb/index.ts index 798be01e5..6b4d73e32 100644 --- a/packages/sdk/src/cli/commands/apply/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/apply/tailordb/index.ts @@ -58,7 +58,12 @@ import { type TailorDBService } from "@/cli/services/tailordb/service"; import { fetchAll, type OperatorClient } from "@/cli/shared/client"; import { logger } from "@/cli/shared/logger"; import { createChangeSet } from "../change-set"; -import { areNormalizedEqual, normalizeProtoConfig } from "../compare"; +import { + areNormalizedEqual, + collectDiffLines, + normalizeProtoConfig, + stableStringify, +} from "../compare"; import { buildMetaRequest, sdkNameLabelKey, trnPrefix, type WithLabel } from "../label"; import { executeMigrations, @@ -291,8 +296,12 @@ async function validateAndDetectMigrations( logger.log(formatRemoteVerificationResults(remoteVerificationResults)); logger.newline(); logger.info("This may indicate:"); - logger.info(" - Another developer applied different migrations", { mode: "plain" }); - logger.info(" - Manual schema changes were made directly", { mode: "plain" }); + logger.info(" - Another developer applied different migrations", { + mode: "plain", + }); + logger.info(" - Manual schema changes were made directly", { + mode: "plain", + }); logger.info(" - Migration history is out of sync", { mode: "plain" }); logger.newline(); logger.info("Use '--no-schema-check' to skip this check (not recommended)."); @@ -993,9 +1002,9 @@ export async function planTailorDB(context: PlanContext) { planGqlPermissions(client, workspaceId, tailordbs, deletedServices), ]); - serviceChangeSet.print(); - typeChangeSet.print(); - gqlPermissionChangeSet.print(); + serviceChangeSet.print({ detail: context.detailPlan }); + typeChangeSet.print({ detail: context.detailPlan }); + gqlPermissionChangeSet.print({ detail: context.detailPlan }); return { changeSet: { @@ -1023,6 +1032,7 @@ type CreateService = { type UpdateService = { name: string; + details?: string[]; metaRequest: MessageInitShape; }; @@ -1135,6 +1145,16 @@ async function planServices( } else { changeSet.updates.push({ name: tailordb.namespace, + details: collectDiffLines( + { + namespace: existing.resource.namespace?.name, + defaultTimezone: existing.resource.defaultTimezone || "UTC", + }, + { + namespace: tailordb.namespace, + defaultTimezone: "UTC", + }, + ), metaRequest, }); } @@ -1179,6 +1199,7 @@ type CreateType = { type UpdateType = { name: string; + details?: string[]; request: MessageInitShape; }; @@ -1269,8 +1290,12 @@ async function planTypes( ) { changeSet.unchanged.push({ name: typeName }); } else { + const current = normalizeComparableTailorDBType(existingType.tailordbType); + const desired = normalizeComparableTailorDBType(tailordbType); + logTailorDBTypeDiff(tailordb.namespace, typeName, current, desired); changeSet.updates.push({ name: typeName, + details: collectDiffLines(current, desired), request: { workspaceId, namespaceName: tailordb.namespace, @@ -1317,6 +1342,88 @@ async function planTypes( return changeSet; } +const shouldDebugTailorDBTypeDiff = process.env.TAILOR_DEBUG_TAILORDB_TYPE_DIFF === "true"; +const maxTailorDBTypeDiffLines = 60; + +function logTailorDBTypeDiff( + namespaceName: string, + typeName: string, + remoteType: ReturnType, + localType: ReturnType, +): void { + if (!shouldDebugTailorDBTypeDiff) { + return; + } + + const diffLines = collectDebugDiffLines(remoteType, localType); + logger.info(`[tailordb:type-diff] ${namespaceName}.${typeName}`); + diffLines.forEach((line) => logger.info(` ${line}`, { mode: "plain" })); +} + +function collectDebugDiffLines(left: unknown, right: unknown): string[] { + const lines: string[] = []; + + const walk = (leftValue: unknown, rightValue: unknown, path: string) => { + if (lines.length >= maxTailorDBTypeDiffLines) { + return; + } + + if (stableStringify(leftValue) === stableStringify(rightValue)) { + return; + } + + if (Array.isArray(leftValue) || Array.isArray(rightValue)) { + const linesBefore = lines.length; + const leftArray = Array.isArray(leftValue) ? leftValue : []; + const rightArray = Array.isArray(rightValue) ? rightValue : []; + const maxLength = Math.max(leftArray.length, rightArray.length); + for (let index = 0; index < maxLength; index += 1) { + walk(leftArray[index], rightArray[index], `${path}[${index}]`); + if (lines.length >= maxTailorDBTypeDiffLines) { + return; + } + } + if (lines.length === linesBefore) { + lines.push( + `${path}: remote=${stableStringify(leftValue)} local=${stableStringify(rightValue)}`, + ); + } + return; + } + + if (isPlainObject(leftValue) || isPlainObject(rightValue)) { + const linesBefore = lines.length; + const leftObject = isPlainObject(leftValue) ? leftValue : {}; + const rightObject = isPlainObject(rightValue) ? rightValue : {}; + const keys = new Set([...Object.keys(leftObject), ...Object.keys(rightObject)]); + for (const key of [...keys].sort()) { + walk(leftObject[key], rightObject[key], path === "$" ? key : `${path}.${key}`); + if (lines.length >= maxTailorDBTypeDiffLines) { + return; + } + } + if (lines.length === linesBefore) { + lines.push( + `${path}: remote=${stableStringify(leftValue)} local=${stableStringify(rightValue)}`, + ); + } + return; + } + + lines.push( + `${path}: remote=${stableStringify(leftValue)} local=${stableStringify(rightValue)}`, + ); + }; + + walk(left, right, "$"); + + if (lines.length >= maxTailorDBTypeDiffLines) { + return [...lines.slice(0, maxTailorDBTypeDiffLines), "...diff output truncated"]; + } + + return lines; +} + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -1681,7 +1788,9 @@ function processNestedFields( vector: false, ...toProtoFieldHooks(nestedFieldConfig), fields: deepNestedFields, - ...(nestedFieldConfig.scale !== undefined && { scale: nestedFieldConfig.scale }), + ...(nestedFieldConfig.scale !== undefined && { + scale: nestedFieldConfig.scale, + }), }; } else { nestedFields[nestedFieldName] = { @@ -1707,7 +1816,9 @@ function processNestedFields( }), }, }), - ...(nestedFieldConfig.scale !== undefined && { scale: nestedFieldConfig.scale }), + ...(nestedFieldConfig.scale !== undefined && { + scale: nestedFieldConfig.scale, + }), }; } }); @@ -1835,6 +1946,7 @@ type CreateGqlPermission = { type UpdateGqlPermission = { name: string; + details?: string[]; request: MessageInitShape; }; @@ -1899,8 +2011,12 @@ async function planGqlPermissions( ) { changeSet.unchanged.push({ name: typeName }); } else { + const current = existingPermission + ? normalizeComparableGqlPermission(existingPermission.permission) + : undefined; changeSet.updates.push({ name: typeName, + details: collectDiffLines(current, normalizeComparableGqlPermission(desiredPermission)), request: { workspaceId, namespaceName: tailordb.namespace, diff --git a/packages/sdk/src/cli/commands/apply/workflow.ts b/packages/sdk/src/cli/commands/apply/workflow.ts index 063c67d53..1913586d7 100644 --- a/packages/sdk/src/cli/commands/apply/workflow.ts +++ b/packages/sdk/src/cli/commands/apply/workflow.ts @@ -2,7 +2,7 @@ import { type ApplyPhase } from "@/cli/commands/apply/apply"; import { parseDuration } from "@/cli/shared/args"; import { type OperatorClient, fetchAll } from "@/cli/shared/client"; import { createChangeSet, type ChangeSet } from "./change-set"; -import { areNormalizedEqual } from "./compare"; +import { areNormalizedEqual, collectDiffLines } from "./compare"; import { workflowJobFunctionName } from "./function-registry"; import { buildMetaRequest, sdkNameLabelKey, type WithLabel } from "./label"; import type { OwnerConflict, UnmanagedResource } from "./confirm"; @@ -214,6 +214,7 @@ type CreateWorkflow = { type UpdateWorkflow = { name: string; + details?: string[]; workspaceId: string; workflow: Workflow; usedJobNames: string[]; @@ -258,6 +259,7 @@ function jobFunctionTrn(workspaceId: string, name: string) { * @param workflows - Parsed workflows * @param mainJobDeps - Main job dependencies by workflow * @param unchangedJobFunctions - Job functions already proven unchanged by function registry plan + * @param detailPlan - Whether to include detailed plan information * @returns Planned workflow changes */ export async function planWorkflow( @@ -267,6 +269,7 @@ export async function planWorkflow( workflows: Record, mainJobDeps: Record, unchangedJobFunctions: ReadonlySet = new Set(), + detailPlan = false, ) { const changeSet = createChangeSet("Workflows"); const conflicts: OwnerConflict[] = []; @@ -342,6 +345,10 @@ export async function planWorkflow( } else { changeSet.updates.push({ name: workflow.name, + details: collectDiffLines( + normalizeComparableWorkflow(existing.resource), + normalizeComparableWorkflow(existing.resource, workflow, usedJobNames), + ), workspaceId, workflow, usedJobNames, @@ -375,7 +382,7 @@ export async function planWorkflow( } }); - changeSet.print(); + changeSet.print({ detail: detailPlan }); return { changeSet, conflicts, @@ -386,6 +393,49 @@ export async function planWorkflow( }; } +function normalizeComparableWorkflow( + existing: { + mainJobFunctionName?: string; + retryPolicy?: { + maxRetries?: number; + backoffMultiplier?: number; + initialBackoff?: { seconds?: bigint; nanos?: number }; + maxBackoff?: { seconds?: bigint; nanos?: number }; + }; + jobFunctions?: Record; + }, + workflow?: Workflow, + usedJobNames: string[] = [], +) { + const retryPolicy = workflow?.retryPolicy + ? normalizeRetryPolicyForCompare({ + maxRetries: workflow.retryPolicy.maxRetries, + backoffMultiplier: workflow.retryPolicy.backoffMultiplier, + initialBackoff: parseDurationToProto(workflow.retryPolicy.initialBackoff), + maxBackoff: parseDurationToProto(workflow.retryPolicy.maxBackoff), + }) + : existing.retryPolicy + ? normalizeRetryPolicyForCompare({ + maxRetries: existing.retryPolicy.maxRetries ?? 0, + backoffMultiplier: existing.retryPolicy.backoffMultiplier ?? 0, + initialBackoff: { + seconds: existing.retryPolicy.initialBackoff?.seconds ?? 0n, + nanos: existing.retryPolicy.initialBackoff?.nanos ?? 0, + }, + maxBackoff: { + seconds: existing.retryPolicy.maxBackoff?.seconds ?? 0n, + nanos: existing.retryPolicy.maxBackoff?.nanos ?? 0, + }, + }) + : undefined; + + return { + mainJobFunctionName: workflow ? workflow.mainJob.name : existing.mainJobFunctionName, + retryPolicy, + jobFunctions: [...(workflow ? usedJobNames : Object.keys(existing.jobFunctions ?? {}))].sort(), + }; +} + function canTreatWorkflowAsUnchanged( existing: { mainJobFunctionName?: string;