diff --git a/docs/app.md b/docs/app.md index c328dbdf0..654f2b3ed 100644 --- a/docs/app.md +++ b/docs/app.md @@ -32,6 +32,7 @@ Manage apps, and app installations in your projects * [`mw app list-upgrade-candidates [INSTALLATION-ID]`](#mw-app-list-upgrade-candidates-installation-id) * [`mw app ssh [INSTALLATION-ID]`](#mw-app-ssh-installation-id) * [`mw app uninstall [INSTALLATION-ID]`](#mw-app-uninstall-installation-id) +* [`mw app update [INSTALLATION-ID]`](#mw-app-update-installation-id) * [`mw app upgrade [INSTALLATION-ID]`](#mw-app-upgrade-installation-id) * [`mw app upload [INSTALLATION-ID]`](#mw-app-upload-installation-id) * [`mw app versions [APP]`](#mw-app-versions-app) @@ -1923,6 +1924,45 @@ FLAG DESCRIPTIONS scripts), you can use this flag to easily get the IDs of created resources for further processing. ``` +## `mw app update [INSTALLATION-ID]` + +Update properties of an app installation (use 'upgrade' to update the app version) + +``` +USAGE + $ mw app update [INSTALLATION-ID] [-q] [--description ] [--entrypoint ] [--document-root ] + +ARGUMENTS + INSTALLATION-ID ID or short ID of an app installation; this argument is optional if a default app installation is set + in the context. + +FLAGS + -q, --quiet suppress process output and only display a machine-readable summary. + --description= update the description of the app installation + --document-root= update the document root of the app installation + --entrypoint= update the entrypoint of the app installation (Python and Node.js only) + +FLAG DESCRIPTIONS + -q, --quiet suppress process output and only display a machine-readable summary. + + This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in + scripts), you can use this flag to easily get the IDs of created resources for further processing. + + --description= update the description of the app installation + + This flag updates the description of the app installation. If omitted, the description will not be changed. + + --document-root= update the document root of the app installation + + Updates the document root of the app installation. If omitted, the document root will not be changed. Note that not + all apps support this field. + + --entrypoint= update the entrypoint of the app installation (Python and Node.js only) + + Updates the entrypoint of the app installation. If omitted, the entrypoint will not be changed. Note that this field + is only available for some types of apps (like Python and Node.js). +``` + ## `mw app upgrade [INSTALLATION-ID]` Upgrade app installation to target version diff --git a/src/commands/app/update.tsx b/src/commands/app/update.tsx new file mode 100644 index 000000000..80cc13a51 --- /dev/null +++ b/src/commands/app/update.tsx @@ -0,0 +1,137 @@ +import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js"; +import React, { ReactNode } from "react"; +import { + appInstallationArgs, + withAppInstallationId, +} from "../../lib/resources/app/flags.js"; +import { + makeProcessRenderer, + processFlags, +} from "../../rendering/process/process_flags.js"; +import { MittwaldAPIV2 } from "@mittwald/api-client"; +import { Success } from "../../rendering/react/components/Success.js"; +import { Value } from "../../rendering/react/components/Value.js"; +import { Flags } from "@oclif/core"; +import { CommandFlags } from "../../lib/basecommands/CommandFlags.js"; + +type AppSavedUserInput = MittwaldAPIV2.Components.Schemas.AppSavedUserInput; +type AppAppUpdatePolicy = MittwaldAPIV2.Components.Schemas.AppAppUpdatePolicy; + +/** + * This is a carefully selected subset of the "patchAppinstallation" request + * body; the type needs to be inlined, because it's not exported from the API + * client. + */ +type UpdateBody = { + customDocumentRoot?: string; + description?: string; + updatePolicy?: AppAppUpdatePolicy; + userInputs?: AppSavedUserInput[]; +}; + +export class Update extends ExecRenderBaseCommand { + static summary = + "Update properties of an app installation (use 'upgrade' to update the app version)"; + static args = { ...appInstallationArgs }; + static flags = { + ...processFlags, + description: Flags.string({ + summary: "update the description of the app installation", + description: + "This flag updates the description of the app installation. If omitted, the description will not be changed.", + default: undefined, + required: false, + }), + entrypoint: Flags.string({ + summary: + "update the entrypoint of the app installation (Python and Node.js only)", + description: + "Updates the entrypoint of the app installation. If omitted, the entrypoint will not be changed. Note that this field is only available for some types of apps (like Python and Node.js).", + required: false, + default: undefined, + }), + "document-root": Flags.string({ + summary: "update the document root of the app installation", + description: + "Updates the document root of the app installation. If omitted, the document root will not be changed. Note that not all apps support this field.", + required: false, + default: undefined, + }), + }; + + protected async exec(): Promise { + const p = makeProcessRenderer(this.flags, "Updating app installation"); + const appInstallationId = await withAppInstallationId( + this.apiClient, + Update, + this.flags, + this.args, + this.config, + ); + + const [updateBody, info] = buildUpdateBodyFromFlags(this.flags); + + info.forEach((i) => p.addInfo(i)); + + if (Object.keys(updateBody).length === 0) { + p.addInfo("skipping update"); + await p.complete(No changes to apply); + return; + } + + this.debug("updating app installation: %O", updateBody); + + await p.runStep("updating app", async () => { + await this.apiClient.app.patchAppinstallation({ + appInstallationId, + data: updateBody, + }); + }); + + await p.complete(App installation successfully updated); + } + + protected render(): React.ReactNode { + return undefined; + } +} + +function buildUpdateBodyFromFlags( + flags: CommandFlags, +): [UpdateBody, ReactNode[]] { + const updateBody: UpdateBody = {}; + const info: ReactNode[] = []; + + if (flags.entrypoint) { + info.push(); + updateBody.userInputs = [ + ...(updateBody.userInputs || []), + { + name: "entrypoint", + value: flags.entrypoint, + }, + ]; + } + + if (flags["document-root"]) { + info.push( + , + ); + updateBody.customDocumentRoot = flags["document-root"]; + } + + if (flags.description !== undefined) { + info.push(); + updateBody.description = flags.description; + } + + return [updateBody, info]; +} + +function UpdateFieldInfo({ name, value }: { name: string; value: string }) { + return ( + <> + setting {name} to {value} + + ); +} diff --git a/src/commands/app/upgrade.tsx b/src/commands/app/upgrade.tsx index 837630599..042bb9376 100644 --- a/src/commands/app/upgrade.tsx +++ b/src/commands/app/upgrade.tsx @@ -19,7 +19,6 @@ import { } from "../../lib/resources/app/versions.js"; import { makeProcessRenderer, - ProcessFlags, processFlags, } from "../../rendering/process/process_flags.js"; import { Success } from "../../rendering/react/components/Success.js"; @@ -28,10 +27,19 @@ import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client"; import { waitUntilAppStateHasNormalized } from "../../lib/resources/app/wait.js"; import { assertStatus } from "@mittwald/api-client-commons"; import { waitFlags } from "../../lib/wait.js"; +import { ProcessFlags } from "../../rendering/process/process_flags.js"; +import semver from "semver/preload.js"; type AppApp = MittwaldAPIV2.Components.Schemas.AppApp; type AppAppInstallation = MittwaldAPIV2.Components.Schemas.AppAppInstallation; type AppAppVersion = MittwaldAPIV2.Components.Schemas.AppAppVersion; +type AppSystemSoftwareVersion = + MittwaldAPIV2.Components.Schemas.AppSystemSoftwareVersion; +type AppSystemSoftwareDependency = + MittwaldAPIV2.Components.Schemas.AppSystemSoftwareDependency; +type AppUpgradePayload = Parameters< + MittwaldAPIV2Client["app"]["patchAppinstallation"] +>[0]["data"]; export class UpgradeApp extends ExecRenderBaseCommand { static description = "Upgrade app installation to target version"; @@ -58,23 +66,23 @@ export class UpgradeApp extends ExecRenderBaseCommand { "App upgrade", ); const appInstallationId: string = await withAppInstallationId( - this.apiClient, - UpgradeApp, - this.flags, - this.args, - this.config, - ), - currentAppInstallation: AppAppInstallation = + this.apiClient, + UpgradeApp, + this.flags, + this.args, + this.config, + ); + const currentAppInstallation: AppAppInstallation = await getAppInstallationFromUuid(this.apiClient, appInstallationId), currentApp: AppApp = await getAppFromUuid( this.apiClient, currentAppInstallation.appId, - ), - targetAppVersionCandidates: AppAppVersion[] = - await getAllUpgradeCandidatesFromAppInstallationId( - this.apiClient, - currentAppInstallation.id, - ); + ); + const targetAppVersionCandidates: AppAppVersion[] = + await getAllUpgradeCandidatesFromAppInstallationId( + this.apiClient, + currentAppInstallation.id, + ); if (currentAppInstallation.appVersion.current === undefined) { process.error("Current version could not be determined properly."); @@ -88,25 +96,24 @@ export class UpgradeApp extends ExecRenderBaseCommand { ); if (targetAppVersionCandidates.length == 0) { - process.addInfo( + process.complete( Your {currentApp.name} {currentAppVersion.externalVersion} is already Up-To-Date. ✅ , ); - process.complete(<>); - ux.exit(0); + return; } - let targetAppVersion; + let targetAppVersion: AppAppVersion; if (this.flags["target-version"] == "latest") { targetAppVersion = - await getLatestAvailableTargetAppVersionForAppVersionUpgradeCandidates( + (await getLatestAvailableTargetAppVersionForAppVersionUpgradeCandidates( this.apiClient, currentApp.id, currentAppVersion.id, - ); + )) as AppAppVersion; } else if (this.flags["target-version"]) { const targetVersionMatchFromCandidates: AppAppVersion | undefined = targetAppVersionCandidates.find( @@ -124,27 +131,27 @@ export class UpgradeApp extends ExecRenderBaseCommand { candidate. , ); - targetAppVersion = await forceTargetVersionSelection( + targetAppVersion = (await forceTargetVersionSelection( process, this.apiClient, targetAppVersionCandidates, currentApp, currentAppVersion, - ); + )) as AppAppVersion; } } else { - targetAppVersion = await forceTargetVersionSelection( + targetAppVersion = (await forceTargetVersionSelection( process, this.apiClient, targetAppVersionCandidates, currentApp, currentAppVersion, - ); + )) as AppAppVersion; } if (!targetAppVersion) { process.error("Target app version could not be determined properly."); - return; + ux.exit(1); } if (!this.flags.force) { @@ -172,10 +179,58 @@ export class UpgradeApp extends ExecRenderBaseCommand { ); } + const appUpgradePayload: AppUpgradePayload = { + appVersionId: targetAppVersion.id, + }; + + const missingDependencies = + await this.apiClient.app.getMissingDependenciesForAppinstallation({ + queryParameters: { + targetAppVersionID: targetAppVersion.id, + }, + appInstallationId, + }); + + if (missingDependencies.data.missingSystemSoftwareDependencies) { + appUpgradePayload.systemSoftware = {}; + process.addStep( + + In order to upgrade your {currentApp.name} to Version{" "} + {targetAppVersion.externalVersion} some dependencies need to be + upgraded too. + , + ); + for (const missingSystemSoftwareDependency of missingDependencies.data + .missingSystemSoftwareDependencies as AppSystemSoftwareDependency[]) { + const dependencyUpdateData = + await updateMissingSystemSoftwareDependency( + process, + this.apiClient, + missingSystemSoftwareDependency, + ); + appUpgradePayload.systemSoftware[ + dependencyUpdateData.dependencySoftwareId + ] = { + systemSoftwareVersion: dependencyUpdateData.dependencyTargetVersionId, + }; + } + + if (!this.flags.force) { + const confirmed: boolean = await process.addConfirmation( + Do you want to continue?, + ); + if (!confirmed) { + process.addInfo(Upgrade will not be triggered.); + process.complete(<>); + ux.exit(1); + } + } + } + const patchAppTriggerResponse = await this.apiClient.app.patchAppinstallation({ appInstallationId, - data: { appVersionId: targetAppVersion.id }, + data: appUpgradePayload, }); assertStatus(patchAppTriggerResponse, 204); @@ -227,3 +282,60 @@ async function forceTargetVersionSelection( targetAppVersionString, ); } + +async function updateMissingSystemSoftwareDependency( + process: ProcessRenderer, + apiClient: MittwaldAPIV2Client, + dependency: AppSystemSoftwareDependency, +) { + const dependencySoftware = await apiClient.app.getSystemsoftware({ + systemSoftwareId: dependency.systemSoftwareId, + }); + assertStatus(dependencySoftware, 200); + + const dependencyVersionList = await apiClient.app.listSystemsoftwareversions({ + systemSoftwareId: dependency.systemSoftwareId, + queryParameters: { + versionRange: dependency.versionRange, + recommended: true, + }, + }); + assertStatus(dependencyVersionList, 200); + + let dependencyTargetVersion: AppSystemSoftwareVersion = { + id: "not yet set", + externalVersion: "0.0.0", + internalVersion: "0.0.0", + }; + + for (const dependencyVersion of dependencyVersionList.data) { + if ( + semver.gt( + dependencyVersion.internalVersion, + dependencyTargetVersion.internalVersion, + ) + ) { + dependencyTargetVersion = dependencyVersion; + } + } + + if (dependencyTargetVersion.internalVersion == "0.0.0") { + throw new Error( + "Dependency Target Version for " + + dependencySoftware.data.name + + " could not be determined", + ); + } else { + process.addInfo( + + {dependencySoftware.data.name as string} will be upgraded to Version{" "} + {dependencyTargetVersion.externalVersion}. + , + ); + + return { + dependencySoftwareId: dependencySoftware.data.id as string, + dependencyTargetVersionId: dependencyTargetVersion.id, + }; + } +} diff --git a/src/rendering/process/process.tsx b/src/rendering/process/process.tsx index fce29bfcd..591a4425c 100644 --- a/src/rendering/process/process.tsx +++ b/src/rendering/process/process.tsx @@ -110,7 +110,7 @@ export interface ProcessRenderer { start(): void; addStep(title: ReactNode): RunnableHandler; runStep(title: ReactNode, fn: () => Promise): Promise; - addInfo(title: ReactElement): void; + addInfo(title: ReactNode): void; addConfirmation(question: ReactElement): Promise; addInput(question: ReactNode, mask?: boolean): Promise; addSelect( diff --git a/src/rendering/process/process_fancy.tsx b/src/rendering/process/process_fancy.tsx index 4715f201d..fb0ea84c8 100644 --- a/src/rendering/process/process_fancy.tsx +++ b/src/rendering/process/process_fancy.tsx @@ -68,7 +68,7 @@ export class FancyProcessRenderer implements ProcessRenderer { } } - public addInfo(title: ReactElement) { + public addInfo(title: ReactNode) { this.start(); if (this.currentHandler !== null) {