diff --git a/landing-zone-configurator/app/apps/api/db/063_application_output_progress.sql b/landing-zone-configurator/app/apps/api/db/063_application_output_progress.sql new file mode 100644 index 0000000..2c9e5ed --- /dev/null +++ b/landing-zone-configurator/app/apps/api/db/063_application_output_progress.sql @@ -0,0 +1,50 @@ +-- Progress snapshots may change only while their bound dispatch is active. +-- Saved Plans and recovery evidence keep their existing immutability contract. +CREATE FUNCTION lzc.guard_application_record() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog AS $$ +BEGIN + IF TG_OP='UPDATE' AND OLD.kind='output' AND + (to_jsonb(NEW)-ARRAY['ciphertext','sha256','truncated']) IS NOT DISTINCT FROM + (to_jsonb(OLD)-ARRAY['ciphertext','sha256','truncated']) AND EXISTS( + SELECT 1 FROM lzc.application_dispatches d + WHERE d.job_id=OLD.job_id AND d.tenant_id=OLD.tenant_id + AND d.owner_user_id=OLD.owner_user_id AND d.runner_package_id=OLD.runner_package_id + AND d.status IN ('starting','initializing','validating','planning','applying') + ) THEN RETURN NEW; END IF; + RAISE EXCEPTION 'immutable_application_version' USING ERRCODE='55000'; +END $$; +REVOKE ALL ON FUNCTION lzc.guard_application_record() FROM PUBLIC; +DROP TRIGGER immutable_application_record ON lzc.application_runner_records; +CREATE TRIGGER immutable_application_record BEFORE UPDATE OR DELETE ON lzc.application_runner_records + FOR EACH ROW EXECUTE FUNCTION lzc.guard_application_record(); + +-- Keep all existing session, delegation, claim, ticket and dispatch checks. +-- Only the output case gains replacement; artifact/recovery conflicts still fail. +DO $$ +DECLARE signature text; definition text; anchor text; replacement text; +BEGIN + signature := 'lzc_auth.application_report_record(uuid,uuid,text,bytea,text,jsonb,boolean)'; + definition := pg_get_functiondef(signature::regprocedure); + anchor := 'IF existing.sha256 IS DISTINCT FROM p_sha'; + replacement := 'IF p_kind=''output'' THEN + UPDATE lzc.application_runner_records SET ciphertext=p_cipher,sha256=p_sha,truncated=p_truncated WHERE job_id=p_job AND kind=''output''; + RETURN p_sha; + END IF; + ' || anchor; + IF strpos(definition,anchor)=0 THEN RAISE EXCEPTION 'application_output_guard_missing'; END IF; + EXECUTE replace(definition,anchor,replacement); + FOREACH signature IN ARRAY ARRAY[ + 'lzc_auth.delegated_application_plan_operation(uuid,uuid,text,jsonb)', + 'lzc_auth.delegated_application_operation(uuid,uuid,text,jsonb)' + ] LOOP + definition := pg_get_functiondef(signature::regprocedure); + anchor := 'IF existing.sha256 IS DISTINCT FROM p_input->>''sha'''; + replacement := 'IF p_input->>''kind''=''output'' THEN + UPDATE lzc.application_runner_records SET ciphertext=decode(p_input->>''cipher'',''hex''),sha256=p_input->>''sha'',truncated=(p_input->>''truncated'')::boolean WHERE job_id=p_job AND kind=''output''; + RETURN jsonb_build_object(''sha256'',p_input->>''sha''); + END IF; + ' || anchor; + IF strpos(definition,anchor)=0 THEN RAISE EXCEPTION 'application_output_guard_missing'; END IF; + EXECUTE replace(definition,anchor,replacement); + END LOOP; +END $$; diff --git a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx index 53781cf..46911bb 100644 --- a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx +++ b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx @@ -941,15 +941,24 @@ function ApplicationPlanControls({ job.status, ), ); + const outputJob = + view === "destroy" + ? jobs.find( + (job) => job.id === currentPlan?.id || job.id === currentApply?.id, + ) + : view === "apply" + ? currentApply + : planView + ? currentPlan + : undefined; const outputJobId = - (view === "apply" || view === "destroy") && - currentApply?.requestedBy === session.user.id - ? currentApply?.id + outputJob && outputJob.requestedBy === session.user.id + ? outputJob.id : undefined; const outputPolling = Boolean( - currentApply && + outputJob && !["succeeded", "failed", "reconciliation_required"].includes( - currentApply.status, + outputJob.status, ), ); const loadOutput = useCallback( @@ -1258,41 +1267,39 @@ function ApplicationPlanControls({ )} )} - {(view === "apply" || view === "destroy") && - job.operation === "apply" && - job.requestedBy === session.user.id && ( -
- {t("Ausführungslogs")} - - {outputError &&

{t(outputError)}

} - {output?.jobId === job.id && output.text ? ( -
-
{output.text}
-
- ) : ( -

{t("Noch keine Ausführungslogs verfügbar.")}

- )} - {output?.jobId === job.id && output.truncated && ( -

{t("Die Ausführungslogs wurden gekürzt.")}

- )} -
- )} + {job.id === outputJobId && job.requestedBy === session.user.id && ( +
+ {t("Ausführungslogs")} + + {outputError &&

{t(outputError)}

} + {output?.jobId === job.id && output.text ? ( +
+
{output.text}
+
+ ) : ( +

{t("Noch keine Ausführungslogs verfügbar.")}

+ )} + {output?.jobId === job.id && output.truncated && ( +

{t("Die Ausführungslogs wurden gekürzt.")}

+ )} +
+ )} {job.requestedBy === session.user.id && (job.status === "reconciliation_required" || (job.operation === "apply" && job.status === "failed")) && ( diff --git a/landing-zone-configurator/app/apps/worker/src/runner.ts b/landing-zone-configurator/app/apps/worker/src/runner.ts index 0540d8e..a67e4cb 100644 --- a/landing-zone-configurator/app/apps/worker/src/runner.ts +++ b/landing-zone-configurator/app/apps/worker/src/runner.ts @@ -578,7 +578,7 @@ export async function runWorker( if (!outputStopped) outputTimer = setTimeout(pollOutput, 500); }); }; - if (broker === "platform") outputTimer = setTimeout(pollOutput, 500); + outputTimer = setTimeout(pollOutput, 500); try { await new Promise((done, fail) => { const child = spawn( diff --git a/landing-zone-configurator/app/e2e/organisation.spec.ts b/landing-zone-configurator/app/e2e/organisation.spec.ts index bf3a6f6..ff83f20 100644 --- a/landing-zone-configurator/app/e2e/organisation.spec.ts +++ b/landing-zone-configurator/app/e2e/organisation.spec.ts @@ -15,6 +15,7 @@ for (const scenario of [ "disabled", "engineer", "dispatch-failed", + "plan-failed", "prepare-retry", "prepare-refresh-retry", "delete-owner", @@ -35,6 +36,7 @@ for (const scenario of [ "archive-blocked", "order-table", "maintenance-destroy", + "maintenance-replan-failed", "maintenance-drift", "maintenance-expired", "maintenance-running", @@ -48,15 +50,22 @@ for (const scenario of [ const backendId = "88888888-8888-4888-8888-888888888888"; const maintenance = scenario.startsWith("maintenance-"); const recoveryReview = scenario.startsWith("recovery-review"); - const purpose = scenario === "maintenance-destroy" ? "destroy" : "drift"; - let maintenanceStarted = false; + const purpose = + scenario === "maintenance-destroy" || + scenario === "maintenance-replan-failed" + ? "destroy" + : "drift"; + const failedPlan = + scenario === "plan-failed" || scenario === "maintenance-replan-failed"; + let maintenanceStarted = scenario === "maintenance-replan-failed"; const initialApply = recoveryReview || scenario.startsWith("apply-") || scenario.startsWith("archive-") || scenario === "order-table" || scenario === "maintenance-expired" || - scenario === "maintenance-running"; + scenario === "maintenance-running" || + scenario === "maintenance-replan-failed"; const engineer = recoveryReview || scenario === "engineer" || @@ -108,7 +117,7 @@ for (const scenario of [ reason: "Reviewed", }, }; - let prepared = engineer || initialApply; + let prepared = engineer || initialApply || scenario === "plan-failed"; let backendApproved = scenario === "delegated-owner" || initialApply; let dispatched = scenario === "delete-started" || initialApply; let deleted = false; @@ -405,9 +414,11 @@ for (const scenario of [ ).toISOString(), operation: "apply", purpose: - maintenanceStarted && !initialApply - ? purpose - : "standard", + scenario === "maintenance-replan-failed" + ? "destroy" + : maintenanceStarted && !initialApply + ? purpose + : "standard", planId: jobId, status: scenario === "apply-running" || @@ -442,11 +453,13 @@ for (const scenario of [ : "99999999-9999-4999-8999-999999999999", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 120000).toISOString(), - status: dispatched - ? succeeded - ? "succeeded" - : "planning" - : "prepared", + status: failedPlan + ? "failed" + : dispatched + ? succeeded + ? "succeeded" + : "planning" + : "prepared", grantActive: true, backendId: backendApproved ? backendId : null, canApproveBackend: @@ -455,7 +468,7 @@ for (const scenario of [ (engineer || delegated) && backendApproved && !dispatched, ...(delegated ? { delegatedExecution: true } : {}), summary: succeeded ? summary : null, - errorCode: null, + errorCode: failedPlan ? "plan_failed" : null, canApply: delegated && succeeded && @@ -464,7 +477,7 @@ for (const scenario of [ artifactSha256: succeeded ? "a".repeat(64) : null, }, ]; - if (applied) { + if (applied && scenario !== "maintenance-replan-failed") { const plan = jobs.find((job) => job.id === jobId); if (plan) jobs.push({ @@ -472,8 +485,22 @@ for (const scenario of [ id: "bbbbbbb2-bbbb-4bbb-8bbb-bbbbbbbbbbbb", }); } + if (scenario === "maintenance-replan-failed") jobs.reverse(); return route.fulfill({ json: { jobs } }); } + if (path === `jobs/${jobId}/output`) { + expect(request.headers()["x-lzc-tenant"]).toBe(tenantId); + expect(requesterId).toBe(userId); + return route.fulfill({ + json: { + text: failedPlan + ? "Error: Corporate plan fixture failure" + : "Refreshing application project...", + truncated: false, + kind: failedPlan ? "execution" : "live", + }, + }); + } if (path === "jobs/aaaaaaa1-aaaa-4aaa-8aaa-aaaaaaaaaaaa/output") return route.fulfill({ json: { @@ -781,6 +808,43 @@ for (const scenario of [ return; } const plan = await details(); + if (scenario === "plan-failed") { + await expect(plan.getByRole("alert")).toContainText("plan_failed"); + const logs = plan.getByRole("region", { + name: "Ausführungslogs", + exact: true, + }); + await expect(logs).toContainText("Error: Corporate plan fixture failure"); + await plan + .getByRole("button", { + name: "Ausführungslogs aktualisieren", + exact: true, + }) + .click(); + await expect(logs).toContainText("Error: Corporate plan fixture failure"); + expect(mutations).toEqual([]); + await page.getByLabel("Sprache", { exact: true }).selectOption("en"); + await expect( + page + .getByRole("region", { name: "Application plan", exact: true }) + .getByRole("region", { name: "Execution logs", exact: true }), + ).toContainText("Corporate plan fixture failure"); + return; + } + if (scenario === "maintenance-replan-failed") { + await page + .getByRole("region", { name: "Bestelldetails", exact: true }) + .getByRole("tab", { name: "Destroy", exact: true }) + .click(); + await expect( + plan.getByRole("region", { name: "Ausführungslogs", exact: true }), + ).toContainText("Error: Corporate plan fixture failure"); + await expect( + plan.getByRole("region", { name: "Ausführungslogs", exact: true }), + ).not.toContainText("Error creating credentials group"); + expect(mutations).toEqual([]); + return; + } if (maintenance) { const orderDetails = page.getByRole("region", { name: "Bestelldetails", diff --git a/landing-zone-configurator/app/integration/identity.test.ts b/landing-zone-configurator/app/integration/identity.test.ts index 6f9aa3a..d39ae7f 100644 --- a/landing-zone-configurator/app/integration/identity.test.ts +++ b/landing-zone-configurator/app/integration/identity.test.ts @@ -5246,10 +5246,37 @@ describe("real PostgreSQL session and tenant boundaries", () => { runner_package_id: ticketPackage.runnerPackageId, sha256: artifactReceipt.sha256, }); - await dispatchApplications.runnerOutput(dispatchedTicket, ticketPackage, { - text: "application plan output", - truncated: false, - }); + for (const text of [ + "package verified", + "providers initialized", + "application plan output", + "application plan output", + ]) + await dispatchApplications.runnerOutput( + dispatchedTicket, + ticketPackage, + { text, truncated: false }, + ); + await expect( + withTenant(pool, owner, (client) => + client.query( + "UPDATE lzc.application_runner_records SET ciphertext=$2 WHERE job_id=$1 AND kind='output'", + [dispatchJob.id, Buffer.alloc(40)], + ), + ), + ).rejects.toMatchObject({ code: "42501" }); + await expect( + migration.query( + "UPDATE lzc.application_runner_records SET state_key='different' WHERE job_id=$1 AND kind='output'", + [dispatchJob.id], + ), + ).rejects.toMatchObject({ code: "55000" }); + await expect( + migration.query( + "UPDATE lzc.application_runner_records SET ciphertext=$2 WHERE job_id=$1 AND kind='artifact'", + [dispatchJob.id, Buffer.alloc(40)], + ), + ).rejects.toMatchObject({ code: "55000" }); const sealedOutput = ( await migration.query( "SELECT ciphertext FROM lzc.application_runner_records WHERE job_id=$1 AND kind='output'", @@ -5293,6 +5320,18 @@ describe("real PostgreSQL session and tenant boundaries", () => { artifactSha256: artifactReceipt.sha256, }), ).rejects.toMatchObject({ status: 401 }); + await expect( + migration.query( + "UPDATE lzc.application_runner_records SET ciphertext=$2 WHERE job_id=$1 AND kind='output'", + [dispatchJob.id, Buffer.alloc(40)], + ), + ).rejects.toMatchObject({ code: "55000" }); + await expect( + dispatchApplications.runnerOutput(dispatchedTicket, ticketPackage, { + text: "late output", + truncated: false, + }), + ).rejects.toMatchObject({ status: 401 }); await expect( migration.query( "DELETE FROM lzc.application_runner_records WHERE job_id=$1", @@ -5962,6 +6001,37 @@ describe("real PostgreSQL session and tenant boundaries", () => { ), ), ).rejects.toMatchObject({ code: "42501" }); + await delegatedApplications.runnerOutput( + delegatedTicket, + ticketPackage, + { text: "temporarily truncated", truncated: true }, + ); + expect( + await delegatedApplications.outputJob(owner, delegatedJob.id), + ).toMatchObject({ truncated: true }); + for (const text of [ + "package verified", + "delegated planning progress", + "delegated planning progress", + ]) + await delegatedApplications.runnerOutput( + delegatedTicket, + ticketPackage, + { text, truncated: false }, + ); + expect( + await delegatedApplications.outputJob(owner, delegatedJob.id), + ).toEqual({ + text: "delegated planning progress", + truncated: false, + kind: "execution", + }); + await expect( + delegatedApplications.outputJob( + { ...peer, tenantId }, + delegatedJob.id, + ), + ).rejects.toMatchObject({ status: 404 }); const artifact = await delegatedApplications.runnerArtifact( delegatedTicket, ticketPackage, @@ -6129,6 +6199,10 @@ describe("real PostgreSQL session and tenant boundaries", () => { ticketPackage, { stage }, ); + await delegatedApplications.runnerOutput(applyTicket, ticketPackage, { + text: "Apply started", + truncated: false, + }); await delegatedApplications.runnerOutput(applyTicket, ticketPackage, { text: "Apply complete", truncated: false, @@ -6136,6 +6210,18 @@ describe("real PostgreSQL session and tenant boundaries", () => { await delegatedApplications.runnerResult(applyTicket, ticketPackage, { status: "succeeded", }); + await expect( + delegatedApplications.runnerOutput(applyTicket, ticketPackage, { + text: "late output", + truncated: false, + }), + ).rejects.toBeDefined(); + await expect( + migration.query( + "UPDATE lzc.application_runner_records SET ciphertext=$2 WHERE job_id=$1 AND kind='output'", + [apply.jobId, Buffer.alloc(40)], + ), + ).rejects.toMatchObject({ code: "55000" }); await expect( delegatedApplications.runnerResult(applyTicket, ticketPackage, { status: "succeeded", diff --git a/landing-zone-configurator/app/tests/platform-worker.test.ts b/landing-zone-configurator/app/tests/platform-worker.test.ts index 6310e03..96ae240 100644 --- a/landing-zone-configurator/app/tests/platform-worker.test.ts +++ b/landing-zone-configurator/app/tests/platform-worker.test.ts @@ -200,6 +200,10 @@ if (phase === "plan") { fs.writeFileSync("plan.bin", ${JSON.stringify(plan.toString())}); console.log(JSON.stringify({type:"version", ui:"1.2", "@message":"OpenTofu 1.12.6"})); console.log(JSON.stringify({type:"diagnostic", "@message":${JSON.stringify(secret)}})); + if (${JSON.stringify(behavior)} === "plan-failure") { + console.log(JSON.stringify({type:"diagnostic", "@message":"Error: Corporate plan fixture failure " + ${JSON.stringify(secret)}})); + process.exit(1); + } if (${JSON.stringify(behavior)} === "live-output") { console.log(JSON.stringify({type:"refresh_start", "@message":"Refreshing state...", hook:{private:"private-event-metadata"}})); process.stdout.write('{"type":"refresh_complete","@message":"unfinished-sensitive-line'); @@ -756,6 +760,30 @@ it("plans the pinned application root with sibling modules and only its instance ).toHaveLength(1); }); +it("retains a redacted failed Application plan diagnostic before reporting failure and cleanup", async () => { + const test = await fixture("platform-plan", "plan-failure"); + applicationInput(test); + expect(await test.execute()).toBe("failed"); + const outputIndex = test.reports.findLastIndex( + ({ path }) => path === "output", + ); + const resultIndex = test.reports.findIndex(({ path }) => path === "result"); + expect(test.reports[outputIndex]?.body).toMatchObject({ + text: expect.stringContaining("Error: Corporate plan fixture failure"), + truncated: false, + }); + expect(JSON.stringify(test.reports[outputIndex]?.body)).not.toContain(secret); + expect(JSON.stringify(test.reports[outputIndex]?.body)).not.toContain(ticket); + expect(outputIndex).toBeLessThan(resultIndex); + expect(test.reports[resultIndex]?.body).toEqual({ + status: "failed", + errorCode: "plan_failed", + }); + expect(test.reports.some(({ path }) => path === "artifact")).toBe(false); + for (const call of await test.calls()) + await expect(stat(call.work)).rejects.toThrow("ENOENT"); +}); + it.each(["destroy", "drift"])( "plans an application %s without executing an Apply", async (purpose) => { @@ -1107,10 +1135,16 @@ it.runIf(process.env.LZC_NATIVE_APPLICATION_RUNNER_TEST === "true")( 120000, ); -it.each([false, true])( - "publishes progress within 1.5s before Plan completion, deduplicates acknowledged output and retries a failed publication: %s", - async (failFirst) => { +it.each([ + { broker: "platform", failFirst: false }, + { broker: "platform", failFirst: true }, + { broker: "application", failFirst: false }, + { broker: "application", failFirst: true }, +])( + "publishes progress within 1.5s before Plan completion, deduplicates acknowledged output and retries a failed publication: $broker / $failFirst", + async ({ broker, failFirst }) => { const test = await fixture("platform-plan", "live-output"); + if (broker === "application") applicationInput(test); let planningAt = 0; let firstProgressAt = 0; let failed = false; diff --git a/landing-zone-configurator/docs/plan-execution.de.md b/landing-zone-configurator/docs/plan-execution.de.md index 4bc0cee..d40c913 100644 --- a/landing-zone-configurator/docs/plan-execution.de.md +++ b/landing-zone-configurator/docs/plan-execution.de.md @@ -145,6 +145,17 @@ es bedeutet nicht, dass die gesamte Laufzeit deaktiviert ist. Ein gespeicherter Apply bleibt separat zu bestätigen und an das genaue Planartefakt gebunden. Ein Quell-Merge aktiviert oder deployt diese Laufzeit nicht. +Die Application-Ausführungslogs sind ein verschlüsselter, begrenzter +Fortschrittsstand. Migration 063 erlaubt den Austausch von Inhalt, Hash und +Kürzungskennzeichen nur während des exakt gebundenen aktiven Dispatchs und unter +den bestehenden Sitzungs-, Delegierungs-, Claim- und Ticketprüfungen. Artefakte, +Recovery- und Bindungsmetadaten bleiben unveränderlich; abgeschlossene Logs +können nicht überschrieben werden. Der Auftraggeber kann gespeicherte +Planfehlerdetails in der Oberfläche lesen, ohne einen weiteren Job zu starten. +Zuvor abgewiesene Log-Aktualisierungen lassen sich aus dem ersten gespeicherten +Fortschrittsstand nicht rekonstruieren. + + ## Gemeinsamer Editor und Ausführungsumfang diff --git a/landing-zone-configurator/docs/plan-execution.md b/landing-zone-configurator/docs/plan-execution.md index 6c08fed..4b972a2 100644 --- a/landing-zone-configurator/docs/plan-execution.md +++ b/landing-zone-configurator/docs/plan-execution.md @@ -79,6 +79,15 @@ The UUIDs are placeholders and must come from the actually applied platform. App Actual Application Plan/Apply uses the separately packaged immutable Application root, instance-specific S3 State and job-bound server-side credential grants. Runtime capabilities report whether the configured Application Runner is enabled; `executionEnabled: false` on compiler/preview responses means those calls did not execute a cloud operation, not that the entire runtime is disabled. A saved-plan Apply remains separately confirmed and bound to the exact artifact. Source merge does not enable or deploy this runtime. +Application execution output is an encrypted, bounded progress snapshot. Migration +063 permits replacement of output content/hash/truncation only while the exact +bound dispatch is active, through the existing session/delegation/claim/ticket +checks. Artifact, recovery and binding metadata remain immutable; terminal output +cannot be rewritten. The requester can read retained Plan failure details in the +UI without starting another job. Previously rejected log updates cannot be +reconstructed from the first stored snapshot. + + ## Common editor and execution scope diff --git a/landing-zone-configurator/docs/user-guide.de.md b/landing-zone-configurator/docs/user-guide.de.md index f9a6159..24f98ca 100644 --- a/landing-zone-configurator/docs/user-guide.de.md +++ b/landing-zone-configurator/docs/user-guide.de.md @@ -98,6 +98,14 @@ Infrastruktur-Apply frei. ## Plan und Apply +Die Bestelldetails zeigen **Ausführungslogs** in den Schritten **Plan**, **Drift** +und **Destroy** sowie beim Apply. Der Benutzer, der den Job angefordert hat, kann +bereinigten Fortschritt und gespeicherte Fehlerausgaben lesen und aktualisieren, +ohne eine neue Ausführung zu starten. Ein Code wie `plan_failed` bezeichnet die +fehlgeschlagene Phase; zur Ursachenklärung dient der OpenTofu-Fehler in den Logs. +Die Logs erteilen keine Plan-/Apply-Berechtigung und werden keinem anderen +Auftraggeber offengelegt. + Wähle die richtige Umgebung und das richtige Ziel, binde die autorisierten technischen Zugangsdaten, erstelle eine Vorbereitung und fordere anschließend einen Plan an. Verfolge die Job-Ausgabe und prüfe die resultierenden Änderungen, Quell-/Konfigurationsversion diff --git a/landing-zone-configurator/docs/user-guide.md b/landing-zone-configurator/docs/user-guide.md index 079a284..7a359bf 100644 --- a/landing-zone-configurator/docs/user-guide.md +++ b/landing-zone-configurator/docs/user-guide.md @@ -87,6 +87,13 @@ its own State key; publication and ordering do not approve an infrastructure App ## Plan and Apply +Application order details show **Execution logs** on the **Plan**, **Drift** and +**Destroy** steps as well as during Apply. The user who requested the job can read +its redacted progress and retained failure output and refresh it without starting +another execution. A code such as `plan_failed` identifies the failed phase; +the OpenTofu error in the logs is needed to diagnose its cause. Logs do not grant +Plan/Apply permission and are not shared with another requester. + Choose the correct environment and target, bind the authorized technical credentials, then create a preparation and request a Plan. Follow the job output and review the resulting changes, source/configuration version and state binding.