diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 780f407ad..787454a64 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: echo number=${VERSION} >> $GITHUB_OUTPUT build: - timeout-minutes: 45 + timeout-minutes: 60 needs: - prepare strategy: @@ -77,7 +77,7 @@ jobs: tags: zpm build-args: BASE=${{ steps.image.outputs.name }} - name: Run temporary registry - timeout-minutes: 15 + timeout-minutes: 5 run: | docker network create zpm docker build -f tests/registry/Dockerfile -t registry-image . diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e90c988c..2da0bf25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - #1117: Add `sync` command for incremental loading of changed files in dev-mode modules. Detects modified files since last sync using SHA-1 hash and recompiles only what is stale. Supports `-delete` for processing removed files and `-test` for running changed test-phase unit tests. +- #986: Database packaging: new `package-database` and `publish-database` commands create an IRIS.DAT-based package that installs via swapping of the routines database rather than compilation of source files. Pass `-dev` to include test resources (`Scope="test"` or `Scope="verify"`) in the package, which are excluded by default. ## [0.10.9] - 2026-08-05 diff --git a/src/cls/IPM/DataType/PhaseName.cls b/src/cls/IPM/DataType/PhaseName.cls index 53098385f..7911a1bdc 100644 --- a/src/cls/IPM/DataType/PhaseName.cls +++ b/src/cls/IPM/DataType/PhaseName.cls @@ -9,7 +9,18 @@ Parameter MAXLEN As INTEGER = 50; /// separated list (where the delimiter is the first character) of logical values. /// If a non-null value is present, then the attribute is restricted to values /// in the list, and the validation code simply checks to see if the value is in the list. -/// Should be kept in sync with %IPM.Lifecycle.Base.Phases -Parameter VALUELIST = ",Clean,Initialize,Reload,*,Validate,ExportData,Compile,Activate,Document,MakeDeployed,Test,Package,Verify,Publish,Configure,Unconfigure,ApplyUpdateSteps,Sync"; +/// +/// Canonical lifecycle phase ordering. Phases execute in this order during a complete +/// lifecycle run (e.g., load with isComplete=1). Each phase name corresponds to a +/// % method on %IPM.Lifecycle.Base and its subclasses. +/// +/// The "*" entry is not a real phase — it is a module-object refresh checkpoint. +/// When ExecutePhases (in %IPM.Storage.Module) encounters "*", it kills and reopens +/// the module and lifecycle objects from storage, picking up any changes made by +/// earlier phases (e.g., module.xml modifications during Reload). No lifecycle method +/// is called for "*", and no resource processor hooks or elements target it. +/// +/// Should be kept in sync with %IPM.Lifecycle.Base.PHASES. +Parameter VALUELIST = ",Clean,Initialize,Reload,*,Validate,ExportData,Compile,Activate,Document,MakeDeployed,Test,Package,Verify,Publish,Configure,Unconfigure,ApplyUpdateSteps"; } diff --git a/src/cls/IPM/Lifecycle/Base.cls b/src/cls/IPM/Lifecycle/Base.cls index ded23b92b..61be2f8c4 100644 --- a/src/cls/IPM/Lifecycle/Base.cls +++ b/src/cls/IPM/Lifecycle/Base.cls @@ -1404,9 +1404,11 @@ Method ExportPythonDependencies( $$$ThrowOnError(..InstallOrDownloadPythonRequirements(root, .pParams, 1)) set wheelsDir = ##class(%File).NormalizeDirectory("wheels", module.Root) if '##class(%File).DirectoryExists(wheelsDir) { - // ExportPythonDependencies called on base module + dependencies so don't want to error if one of - // the modules doesn't have a wheels directory since that might actually be the case for the module - write !, "WARNING: No wheels directory for this module: "_wheelsDir_" not found" + // A module without Python requirements legitimately has no wheels directory, so this is only worth warning about + // when requirements.txt exists but the download produced nothing. + if ##class(%File).Exists(##class(%File).NormalizeFilename("requirements.txt", root)) { + write !, "WARNING: No wheels directory for this module: "_wheelsDir_" not found" + } quit } set stmt = ##class(%SQL.Statement).%New() @@ -1620,7 +1622,13 @@ Method %Publish(ByRef pParams) As %Status quit } set tModule.VersionString = ..Module.VersionString - if (..Module.Deployed) { + // Publish type comes from the running lifecycle, not the module's Packaging property. + // ..Module.Packaging records how the module was loaded (may be stale); the PACKAGING + // parameter is fixed per lifecycle class and reflects how it's being published now. + if ($parameter($this, "PACKAGING") = "database") { + set tModule.IPMPackaging = "database" + set tModule.PlatformVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + } elseif (..Module.Deployed) { set tModule.Deployed = 1 if $isobject(..Module.SystemRequirements) { set tModule.PlatformVersion = ..Module.SystemRequirements.VersionString @@ -2127,6 +2135,9 @@ Method ExportSingleModule( } } +/// Returns the lifecycle class whose Final PACKAGING parameter equals pPackaging. +/// pBaseClass is "" when no class claims that value. This is a valid outcome, since packaging values are +/// not constrained to those with a dedicated lifecycle class. Callers decide the fallback. ClassMethod GetBaseClassForPackaging( pPackaging As %String, Output pBaseClass As %Dictionary.Classname) As %Status [ Final ] @@ -2145,8 +2156,6 @@ ClassMethod GetBaseClassForPackaging( set pBaseClass = tRes.%Get("BaseClass") } $$$ThrowOnError(tSC) - - // TODO: Error if no results? } catch e { set pBaseClass = "" set tSC = e.AsStatus() diff --git a/src/cls/IPM/Lifecycle/Database.cls b/src/cls/IPM/Lifecycle/Database.cls new file mode 100644 index 000000000..3eddcfeb9 --- /dev/null +++ b/src/cls/IPM/Lifecycle/Database.cls @@ -0,0 +1,1814 @@ +Include %syPrompt + +/// Database packaging lifecycle. Packages a module as an IRIS.DAT file inside a .tgz. +/// The .tgz contains IRIS.DAT, an enriched module.xml with SHA-256 checksum, +/// and a deps/ directory containing one manifest per dependency. +Class %IPM.Lifecycle.Database Extends %IPM.Lifecycle.Base +{ + +/// Binary stream holding the packaged .tgz file for the Publish phase. +Property Payload As %Stream.TmpBinary [ Private ]; + +Parameter PACKAGING As STRING [ Final ] = "database"; + +// ===================================================================== +// Lifecycle overrides +// ===================================================================== + +/// Entry point for database packaging. Orchestrates the full pipeline: +/// 1. Validate IPM is externally mapped (not in routines DB) and routines DB is not mirrored +/// 2. Create a temp IRIS.DAT (or use the current DB with -use-current-db) and export Generated +/// resources from the source namespace (they become inaccessible after dismount) +/// 3. Remap the namespace routines to the temp DB +/// 4. Load and compile this module's resources plus all transitive dependencies into the temp DB +/// 5. Restore the namespace to its original routines DB +/// 6. Assemble staging dir (IRIS.DAT + module.xml with the staged IRIS.DAT's SHA-256 checksum +/// + deps/ manifests + non-compiled resources) and create the .tgz archive +/// +/// On error during remap, a safety net restores the namespace. +/// Temp files and directories are cleaned up in all paths. +Method %Package(ByRef params) As %Status +{ + set sc = $$$OK + set tempDBName = "" + set tempDBPath = "" + set oldDBName = "" + set stagingDir = "" + set dbRemapped = 0 + set generatedExports = "" + set ns = $namespace + new $namespace + + try { + set verbose = $get(params("Verbose")) + set includeTests = $get(params("DeveloperMode"), 0) + set useCurrentDB = $get(params("UseCurrentDB"), 0) + set dbName = $get(params("DatabaseName"), "") + + write:verbose !, "Starting database packaging: ", ..Module.Name, " v", ..Module.VersionString + + write:verbose !, "Validating IPM mapping..." + do ..ValidateIPMNotInRoutinesDB() + do ..ValidateRoutinesDatabaseNotMirrored(ns) + + do ..WarnUnrunnableInvokes() + do ..WarnMappingsNotApplied() + + if useCurrentDB { + set curDBName = ..GetRoutinesDBName(ns) + set tempDBPath = ..GetDatabaseDirectory(curDBName) + } else { + write:verbose !, "Creating temporary database..." + do ..CreateTempDatabase(.dbName, .tempDBPath) + set tempDBName = dbName + + // Export Generated resources from source namespace BEFORE remapping. + // Once the old DB is dismounted, compiled Generated classes are inaccessible. + write:verbose !, "Exporting generated resources from source namespace..." + do ..ExportGeneratedResources(includeTests, .generatedExports) + + write:verbose !, "Remapping namespace routines to temp database..." + set dbRemapped = 1 + do ..RemapRoutinesDB(ns, tempDBName, .oldDBName) + + write:verbose !, "Loading resources into temp database..." + do ..LoadResourcesIntoDatabase(ns, includeTests, .params) + + if $listlength(generatedExports) > 0 { + write:verbose !, "Importing generated resources into temp database..." + set ptr = 0 + while $listnext(generatedExports, ptr, tempFile) { + $$$ThrowOnError($system.OBJ.Load(tempFile, "ck")) + do ##class(%Library.File).Delete(tempFile) + } + set generatedExports = "" + } + + write:verbose !, "Restoring namespace routines..." + do ..RestoreRoutinesDB(ns, oldDBName, tempDBName, tempDBPath) + set dbRemapped = 0 + } + + set irisDAT = tempDBPath _ "IRIS.DAT" + + if $get(params("ExportPythonDependencies"), 1) { + do ..ExportPythonDependencies(..Module, .params) + } + + // Assemble staging dir and create .tgz + set stagingDir = ##class(%Library.File).NormalizeDirectory(##class(%IPM.Utils.File).CreateTempDirectory() _ "stage") + set packageFile = ..AssemblePackage(irisDAT, stagingDir, .params, 'useCurrentDB) + set params("PackageFile") = packageFile + + // Store in Payload for the Publish phase + set srcStream = ##class(%Stream.FileBinary).%New() + $$$ThrowOnError(srcStream.LinkToFile(packageFile)) + $$$ThrowOnError(..Payload.CopyFrom(srcStream)) + + } catch ex { + set sc = ex.AsStatus() + } + + // Safety net: restore namespace if an error occurred mid-remap + if dbRemapped && (oldDBName '= "") { + set restoreSC = $$$OK + try { + do ..RestoreRoutinesDB(ns, oldDBName, tempDBName, tempDBPath) + } catch restoreEx { + set restoreSC = restoreEx.AsStatus() + } + if $$$ISERR(restoreSC) { + write !, "ERROR: Failed to restore namespace routines DB — manual intervention required." + write !, $system.Status.GetErrorText(restoreSC) + } + } + + // Clean up any generated resource export temp files not yet imported (error path) + set ptr = 0 + while $listnext(generatedExports, ptr, tempFile) { + if ##class(%Library.File).Exists(tempFile) { + do ##class(%Library.File).Delete(tempFile) + } + } + + // Clean up staging directory + if (stagingDir '= "") && ##class(%Library.File).DirectoryExists(stagingDir) { + do ##class(%IPM.Utils.File).RemoveDirectoryTree(stagingDir) + } + + // Clean up temp DB directory (IRIS.DAT was moved to staging by AssemblePackage — only the empty dir remains; + // for -use-current-db, IRIS.DAT was copied not moved, so it still exists here but tempDBName="" skips this block) + if (tempDBName '= "") && (tempDBPath '= "") && ##class(%Library.File).DirectoryExists(tempDBPath) { + set $namespace = "%SYS" + do ##class(%Library.File).RemoveDirectoryTree(tempDBPath) + } + + quit sc +} + +/// Dispatches to one of four contexts: installing a package, loading resources while building one, +/// the no-op Reload that precedes Package, or an ordinary source load. A package directory whose +/// IRIS.DAT a completed swap already moved away is refused rather than dispatched. +/// All install work happens here so %Validate, %Compile, and %Activate stay no-ops, and invokes +/// from "Reload.After" onward fire against the mounted database. +Method %Reload(ByRef params) As %Status +{ + if ..IsInstallContext() { + quit ..DoDatabaseInstall(.params) + } + if $get(params("DatabasePackaging")) { + quit ..LoadResourcesForPackaging(.params) + } + // Packaging phase chain on the source module (e.g. the Reload phase that precedes Package + // during `package-database `) has no source root to load from. %Package loads resources + // into the temp DB itself, so this phase is a no-op. We must NOT delegate to ##super here: + // Base.%Reload treats DeveloperMode as a signal to load from ..Module.Root even when no + // RootDirectory is passed, which would reload source into the current namespace and mutate + // it mid-package. + if '$data(params("RootDirectory")) { + quit $$$OK + } + // A packaged manifest with no IRIS.DAT beside it is what an install that already swapped leaves + // behind: PerformDatabaseSwap moves IRIS.DAT into the database directory rather than copying it. + // Loading as source from here would compile source into the swapped-in database, which is meant + // to be sealed and is described by the checksum in its own manifest. + if ..IsPackagedManifest(params("RootDirectory")) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "This directory holds a database package whose IRIS.DAT is no longer present (" _ params("RootDirectory") _ "). The swap that has already run moved it into the database directory. Re-run the install from the original .tgz rather than from this directory.")) + } + // Loading a database-packaged module from a source directory (RootDirectory set) behaves + // like a normal source load so delegate to Base. + quit ##super(.params) +} + +/// Validate, swap the DB, register metadata, and run post-swap steps. +/// Injects IsInstallContext into params before the swap so downstream phases can check it without +/// re-examining the filesystem. Rollback is performed only on swap-mechanics failures; post-swap +/// failures are reported forward because the swap is the state to keep. +Method DoDatabaseInstall(ByRef params) As %Status [ Private ] +{ + set params("IsInstallContext") = 1 + set sc = $$$OK + set dbDir = "" + set backupPath = "" + set swapCompleted = 0 + set staleModules = "" + try { + if $get(params("DeveloperMode")) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Database packages cannot be installed in developer mode.")) + } + + set ns = $namespace + set packageDir = ..Module.Root + + do ..ValidateBeforeSwap(packageDir, .params, .staleModules) + + if '$get(params("SwapDB")) { + do ..PromptForSwapConfirmation(ns, staleModules) + } + + // Runs before the swap, while these modules' code is still mounted: undoes the part of + // their installation the swap cannot reach (web apps, copied files, installer classes). + do ..UnconfigureStaleModules(staleModules, .params) + + do ..PerformDatabaseSwap(packageDir, ns, .dbDir, .backupPath) + // Past this point the swap is the state to keep, not to undo: a rollback would discard a + // good swap and could not reverse the side effects of the steps that follow. + set swapCompleted = 1 + + do ##class(%IPM.Lifecycle.Database).RegisterDependencyMetadata(packageDir) + + // Dependencies get their phases here rather than in a chain of their own: at this point the + // swap has landed, so their compiled code is mounted and their staged files are reachable. + do ..ApplyDependencyResources(packageDir, .params, .depInitClasses) + do ..ReassertRootInitializeResources(.depInitClasses, .params) + + // Only once the swap and metadata registration have both succeeded: the code behind these + // modules is gone, so their records would otherwise linger in 'list' with nothing behind + // them. Doing this earlier would leave the metadata deleted if a rollback restored the code. + do ..RemoveStaleModuleMetadata(staleModules) + + // Seed all update steps as already-run on a fresh install so a subsequent 'update' + // only executes steps introduced after this version. Same logic as Module.cls:%Activate. + // Skipped when params("Update") is set since we're actually running an update, not a fresh install. + if '$get(params("Update")) { + $$$ThrowOnError(..Module.HandleAllUpdateSteps(, 1, $get(params("Verbose")))) + } + + write !, "Database swap complete for namespace ", ns + write !, " Backup: ", backupPath + + } catch ex { + set sc = ex.AsStatus() + if swapCompleted { + do ..ReportPostSwapFailure(sc, $get(ns, $namespace), backupPath) + } elseif dbDir '= "" { + write !, "ERROR: encountered an error: ", $system.Status.GetErrorText(sc) + write !, "Attempting to roll back system state..." + do ..RollbackDatabaseSwap(dbDir, backupPath) + write !, "Finished rollback attempt. Please verify whether namespace state is as expected prior to performing the DB swap." + if staleModules '= "" { + write !, "WARNING: the rollback restored the previous IRIS.DAT, but it cannot restore what was already" + write !, " unconfigured before the swap. These modules had their web applications, copied files and" + write !, " installer-class effects removed and are now only partially installed: ", $listtostring(staleModules, ", ") + write !, " Reinstall them to return the namespace to a working state." + } + } + } + quit sc +} + +/// Loads this module's resources plus all transitive dependencies into the current (temp) DB. +/// Cannot delegate to ##super: Base.%Reload applies package/routine mappings from module.xml, +/// which are inappropriate for a fresh isolated DB. If Base.%Reload gains new loading logic, +/// this branch must be updated to match. +Method LoadResourcesForPackaging(ByRef params) As %Status [ Private ] +{ + set sc = $$$OK + try { + // Load this module's own resources followed by all transitive dependencies. + // The resulting IRIS.DAT must be self-contained. Dependency code must be compiled in + // because at install time only IPM metadata is registered from deps/ manifests. + set modulesToLoad = ..GetSelfAndDependencyModules() + + set mKey = "" + for { + #dim loadModule As %IPM.Storage.Module + set loadModule = modulesToLoad.GetNext(.mKey) + quit:mKey="" + + set orderedResourceList = loadModule.GetOrderedResourceList() + set key = "" + for { + #dim resource As %IPM.Storage.ResourceReference + set resource = orderedResourceList.GetNext(.key) + quit:key="" + + // Type A: regular resources with Scope="test" or Scope="verify" — skip unless in dev mode + if '$get(params("DeveloperMode")) && resource.IsTestScoped() { + continue + } + + // Type B: parent resources have Scope="" and a Test processor. + // OnPhase("Reload") is a no-op for this processor; loading is done via LoadTestDirectory. + if $isobject(resource.Processor) && resource.Processor.%IsA("%IPM.ResourceProcessor.Test") && ($extract(resource.Name, *) = "/") { + if $get(params("DeveloperMode")) { + set testDir = ##class(%Library.File).NormalizeDirectory(resource.Module.Root _ resource.Name) + set format = resource.Processor.Format + set loadSC = ##class(%IPM.Test.Manager).LoadTestDirectory(testDir, $get(params("Verbose")), , format) + $$$ThrowOnError(loadSC) + } + continue + } + + if $isobject(resource.Processor) { + set reloadPhase = $select(resource.Scope '= "": resource.Scope, 1: "Reload") + $$$ThrowOnError(resource.Processor.OnPhase(reloadPhase, .params)) + } + } + } + } catch ex { + set sc = ex.AsStatus() + } + quit sc +} + +/// During a database package install, there is no source to validate. +/// In all other contexts, delegate to ##super. +Method %Validate(ByRef pParams) As %Status +{ + if $get(pParams("IsInstallContext")) { + quit $$$OK + } + quit ##super(.pParams) +} + +/// During a database package install, there is no source to compile. +/// During database packaging, compile all transitive dependencies into the temp DB +/// before this module's own resources. %Reload loaded the dependency source but +/// Base.%Compile only handles this module's resources, so dependency code would +/// otherwise never be compiled into the self-contained IRIS.DAT. +/// In all other contexts, delegate to ##super. +Method %Compile(ByRef pParams) As %Status +{ + if $get(pParams("IsInstallContext")) { + quit $$$OK + } + set sc = $$$OK + try { + if $get(pParams("DatabasePackaging")) { + // Compile each dependency via its own lifecycle (which compiles that module's + // resources). Definitions for all dependencies are already loaded by %Reload, so + // the class compiler resolves cross-module compile order regardless of iteration order. + // Strip DatabasePackaging from the params passed down so a Database-lifecycle dependency + // does not re-enter this branch and recurse; each dependency only compiles its own resources. + merge depParams = pParams + kill depParams("DatabasePackaging") + set deps = ..GetDependencyModules(..Module) + set mKey = "" + for { + #dim depModule As %IPM.Storage.Module + set depModule = deps.GetNext(.mKey) + quit:mKey="" + $$$ThrowOnError(depModule.Lifecycle.%Compile(.depParams)) + } + } + // Compile this module's own resources. + set sc = ##super(.pParams) + } catch ex { + set sc = ex.AsStatus() + } + quit sc +} + +/// During a database package install, the DB swap and all setup already completed in %Reload. +/// In all other contexts, delegate to ##super. +Method %Activate(ByRef params) As %Status +{ + if $get(params("IsInstallContext")) { + quit $$$OK + } + quit ##super(.params) +} + +// ===================================================================== +// Packaging helpers +// ===================================================================== + +/// Warns that elements are not applied by a database install. +/// Mappings are applied by Base.%Reload, which this path does not run. Applying them here instead +/// deadlocks against a merge: the config locks Config.Map* takes are held until the install's +/// transaction commits, and the merge shells out to 'iris merge', which cannot wait that out. +/// Source installs are unaffected — they merge at Initialize, before mappings apply at Reload. +Method WarnMappingsNotApplied() +{ + set modules = ..GetSelfAndDependencyModules() + + set mKey = "" + for { + #dim module As %IPM.Storage.Module + set module = modules.GetNext(.mKey) + quit:mKey="" + continue:'module.Mappings.Count() + + set isRoot = (module.Name = ..Module.Name) + write !, "WARNING: ", module.Mappings.Count(), " element(s) in " + write $select(isRoot: "this module", 1: "dependency '" _ module.Name _ "'") + write " will not be applied during database install." + write !, " Globals, packages and routines these map from another database will resolve locally instead." + write !, " Apply them manually after install, or install this module from source." + } +} + +/// Warns about elements that no phase of a database install will reach. +/// Root: the DB swap runs inside %Reload, so anything before it (the whole Initialize phase and +/// Reload.Before) runs while the old database is still mounted and cannot see the packaged code. +/// Dependencies: phases in GetDependencyExcludedPhases never run for them, so an invoke on one of +/// those phases is unreachable in both When positions. +/// Custom phases are excluded as they are on-demand operations, never part of a standard install. +Method WarnUnrunnableInvokes() +{ + set modules = ..GetSelfAndDependencyModules() + + set mKey = "" + for { + #dim module As %IPM.Storage.Module + set module = modules.GetNext(.mKey) + quit:mKey="" + + set isRoot = (module.Name = ..Module.Name) + set key = "" + for { + #dim invoke As %IPM.Storage.InvokeReference + set invoke = module.Invokes.GetNext(.key) + quit:(key = "") + if invoke.CustomPhase '= "" { + continue + } + + set unreachable = 0 + if isRoot { + set unreachable = (invoke.Phase = "Initialize") || ((invoke.Phase = "Reload") && (invoke.When = "Before")) + } else { + // Same source of truth as the dependency pass, so the two cannot drift apart. + set unreachable = $listfind(..GetDependencyExcludedPhases(), invoke.Phase) > 0 + } + if 'unreachable { + continue + } + + write !, "WARNING: " + write " in ", $select(isRoot: "this module", 1: "dependency '" _ module.Name _ "'"), " will not run during database install." + if isRoot { + write !, " The DB swap runs inside %Reload, so Initialize and Reload.Before both fire before the packaged DB is mounted." + write !, " Use Phase=""Reload"" When=""After"" or a later phase for install-time setup." + } else { + // An update install runs one extra phase per dependency, so name both sets. + write !, " Dependencies run ", $listtostring(..GetDependencyInstallPhases(0), ", "), " after the swap" + write !, " (plus ApplyUpdateSteps when the install is an update); their code is already compiled into IRIS.DAT." + write !, " Use one of those phases for install-time setup in a dependency." + } + } + } +} + +/// Throws if IPM classes are in the local routines database. IPM must be mapped so it survives the swap. +ClassMethod ValidateIPMNotInRoutinesDB() +{ + if ##class(%Dictionary.ClassDefinition).%ExistsId("%IPM.Main") && '##class(%Library.RoutineMgr).IsMapped("%IPM.Main.CLS") { + $$$ThrowStatus($$$ERROR($$$GeneralError,"IPM must be mapped from another namespace. %IPM classes were found in the current routines database. Map IPM from a non-system code namespace before packaging.")) + } +} + +/// Throws if the namespace's routines DB is mirrored. Dismounting a mirrored DB breaks the mirror. +ClassMethod ValidateRoutinesDatabaseNotMirrored(ns As %String) +{ + new $namespace + set $namespace = "%SYS" + set dbName = ..GetRoutinesDBName(ns) + set dbDir = ..GetDatabaseDirectory(dbName) + set db = ##class(SYS.Database).%OpenId(dbDir, , .sc) + $$$ThrowOnError(sc) + if db.Mirrored { + set msg = "Database packaging is not currently supported for mirrored databases (namespace: " _ ns _ ", database: " _ dbName _ ")." + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } +} + +/// Throws if any other namespace uses the same routines database. The swap replaces the IRIS.DAT +/// behind that database, so every namespace pointing at it silently gets the packaged code too. +/// Matches on directory rather than database name: two Config.Databases entries can name the same +/// directory, and only the directory identifies the file the swap actually replaces. +ClassMethod ValidateRoutinesDatabaseNotShared(ns As %String) +{ + set dbDir = ..GetDatabaseDirectory(..GetRoutinesDBName(ns)) + new $namespace + set $namespace = "%SYS" + + set dbResult = ##class(%SQL.Statement).%ExecDirect(, "select Name, Server, Directory from Config.Databases_List()") + if dbResult.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error listing configured databases: " _ dbResult.%Message)) + } + while dbResult.%Next(.sc) { + $$$ThrowOnError(sc) + // Remote databases are distinct files even when the path matches. + continue:dbResult.Server'="" + if ##class(%Library.File).NormalizeDirectory(dbResult.Directory) = dbDir { + set sameDir($zconvert(dbResult.Name, "U")) = "" + } + } + $$$ThrowOnError(sc) + + set sharing = "" + set nsResult = ##class(%SQL.Statement).%ExecDirect(, "select Namespace, Routines from Config.Namespaces_List()") + if nsResult.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error listing configured namespaces: " _ nsResult.%Message)) + } + while nsResult.%Next(.sc) { + $$$ThrowOnError(sc) + continue:$zconvert(nsResult.Namespace,"U")=$zconvert(ns,"U") + if $data(sameDir($zconvert(nsResult.Routines, "U"))) { + set sharing = sharing _ $listbuild(nsResult.Namespace) + } + } + $$$ThrowOnError(sc) + + if sharing '= "" { + set msg = "The routines database of namespace " _ ns _ " (" _ dbDir _ ") is also the routines database of: " _ $listtostring(sharing, ", ") _ ". Swapping it would replace the code of those namespaces as well. Give " _ ns _ " a routines database of its own before installing a database package." + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } +} + +/// Creates an empty IRIS.DAT, registers it in Config.Databases, and mounts it. +/// dbName is auto-generated if empty. dbPath (output): directory path containing IRIS.DAT. +ClassMethod CreateTempDatabase( + ByRef dbName As %String, + Output dbPath As %String) +{ + new $namespace + set $namespace = "%SYS" + + if dbName = "" { + set dbName = "IPMDBPKG" _ $translate( + $system.Encryption.Base64Encode($system.Encryption.GenCryptRand(6)), "+/=", "") + } + + set dbPath = ..GetDatabaseDirectory(dbName) + + if '##class(%Library.File).CreateDirectory(dbPath, .result) { + $$$ThrowStatus($$$ERROR($$$GeneralError,"Failed to create temp database directory: " _ dbPath _ " - " _ result)) + } + + $$$ThrowOnError(##class(SYS.Database).CreateDatabase(dbPath)) + + set dbProps("Directory") = dbPath + set dbProps("MountRequired") = 0 + $$$ThrowOnError(##class(Config.Databases).Create(dbName, .dbProps)) + + set mountSC = ##class(SYS.Database).MountDatabase(dbPath) + if $$$ISERR(mountSC) && '$system.Status.Equals(mountSC, $$$ERRORCODE($$$AlreadyMounted)) { + $$$ThrowOnError(mountSC) + } +} + +/// Exports each Generated resource of this module and its transitive dependencies from the source +/// namespace to a temp XML file for later import. +/// Must run before RemapRoutinesDB: once the old DB is dismounted the compiled classes are gone. +/// +/// Carries Generated resources that no lifecycle phase recreates, e.g. classes built by external +/// tools. Resources that packaging does regenerate are exported too; re-importing them is +/// redundant but harmless. +/// includeTestResources: if 1, test- and verify-scoped resources are also exported. +/// generatedExports (output): $list of temp XML file paths, one per exported class. +Method ExportGeneratedResources( + includeTestResources As %Boolean, + Output generatedExports As %List) [ Private ] +{ + set generatedExports = "" + + // Dependency resources are compiled into the same IRIS.DAT, so their Generated resources must + // be carried over as well. + set modules = ..GetSelfAndDependencyModules() + + set mKey = "" + for { + #dim module As %IPM.Storage.Module + set module = modules.GetNext(.mKey) + quit:mKey="" + + set orderedList = module.GetOrderedResourceList() + set rKey = "" + for { + #dim resource As %IPM.Storage.ResourceReference + set resource = orderedList.GetNext(.rKey) + quit:rKey="" + + if 'includeTestResources && resource.IsTestScoped() { + continue + } + + if 'resource.Generated { + continue + } + + set tempFile = ##class(%Library.File).TempFilename("xml") + set exportSC = $system.OBJ.Export(resource.Name, tempFile, "-d") + if $$$ISERR(exportSC) { + do ##class(%IPM.General.LogManager).Warning("Generated resource '" _ resource.Name _ "' could not be exported and will be missing from this package: " _ $system.Status.GetErrorText(exportSC)) + } elseif ##class(%Library.File).GetFileSize(tempFile) > 0 { + set generatedExports = generatedExports _ $listbuild(tempFile) + continue + } + // Nothing usable was written: an empty export means the resource matched no compiled + // classes, which is expected for resources this namespace never generated. + if ##class(%Library.File).Exists(tempFile) { + do ##class(%Library.File).Delete(tempFile) + } + } + } +} + +/// Remaps the namespace's routines to newDBName, then dismounts the old one. +/// oldDBName (output): previous routines DB name for later restoration. +ClassMethod RemapRoutinesDB( + ns As %String, + newDBName As %String, + Output oldDBName As %String) +{ + new $namespace + set $namespace = "%SYS" + + $$$ThrowOnError(##class(Config.Namespaces).Get(ns, .nsProps)) + set oldDBName = $get(nsProps("Routines")) + + set oldPath = ..GetDatabaseDirectory(oldDBName) + + // Remap before dismounting so the namespace never briefly has no routines DB + set nsProps("Routines") = newDBName + $$$ThrowOnError(##class(Config.Namespaces).Modify(ns, .nsProps)) + $$$ThrowOnError(##class(SYS.Database).DismountDatabase(oldPath)) +} + +/// Restores the namespace routines to oldDBName and dismounts the temp DB. +/// Logs warnings on partial failures but always attempts full restoration. +ClassMethod RestoreRoutinesDB( + ns As %String, + oldDBName As %String, + tempDBName As %String, + tempDBPath As %String) +{ + new $namespace + set $namespace = "%SYS" + + set dismountSC = ##class(SYS.Database).DismountDatabase(tempDBPath) + if $$$ISERR(dismountSC) { + write !, "WARNING: failed to dismount temp DB: ", $system.Status.GetErrorText(dismountSC) + } + + // Remap namespace back to original DB + $$$ThrowOnError(##class(Config.Namespaces).Get(ns, .nsProps)) + set nsProps("Routines") = oldDBName + $$$ThrowOnError(##class(Config.Namespaces).Modify(ns, .nsProps)) + + // Remount original DB + set mountSC = ##class(SYS.Database).MountDatabase(..GetDatabaseDirectory(oldDBName)) + if $$$ISERR(mountSC) && '$system.Status.Equals(mountSC, $$$ERRORCODE($$$AlreadyMounted)) { + $$$ThrowOnError(mountSC) + } + + // Remove temp DB config entry only — caller cleans up the directory + // after it is done reading IRIS.DAT (e.g. after AssemblePackage). + if ##class(Config.Databases).Exists(tempDBName) { + $$$ThrowOnError(##class(Config.Databases).Delete(tempDBName)) + } +} + +/// Runs %Reload and %Compile in ns (which maps to the temp DB) to load and compile resources into it. +Method LoadResourcesIntoDatabase( + ns As %String, + includeTestResources As %Boolean, + ByRef params) +{ + new $namespace + set $namespace = ns + + set loadParams("Verbose") = $get(params("Verbose")) + set loadParams("DatabasePackaging") = 1 + set loadParams("DeveloperMode") = includeTestResources + + $$$ThrowOnError(..%Reload(.loadParams)) + $$$ThrowOnError(..%Compile(.loadParams)) +} + +/// Computes the SHA-256 hash of a file and returns it as a lowercase hex string (64 chars). +ClassMethod ComputeSHA256Hex(filePath As %String) As %String +{ + set stream = ##class(%Stream.FileBinary).%New() + $$$ThrowOnError(stream.LinkToFile(filePath)) + + set binHash = ##class(%SYSTEM.Encryption).SHAHashStream(256, stream, .sc) + $$$ThrowOnError(sc) + + // %xsd.hexBinary.LogicalToXSD returns uppercase hex; lowercase to match the on-disk convention. + quit $zconvert(##class(%xsd.hexBinary).LogicalToXSD(binHash), "L") +} + +/// Assembles staging dir and creates the final .tgz. Returns path to .tgz. +/// moveFile=0 copies IRIS.DAT instead of renaming; required for -use-current-db. +Method AssemblePackage( + irisDAT As %String, + stagingDir As %String, + ByRef params, + moveFile As %Boolean = 1) As %String +{ + if '##class(%Library.File).CreateDirectoryChain(stagingDir, .result) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot create staging dir: " _ result)) + } + + if moveFile { + // Move IRIS.DAT into staging avoids copying potentially multi-GB files. + // Safe because the temp DB is already dismounted before AssemblePackage runs. + if '##class(%Library.File).Rename(irisDAT, stagingDir _ "IRIS.DAT") { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot move IRIS.DAT to staging dir")) + } + } else { + // -use-current-db: copy instead as irisDAT is the live routines DB and must not be moved + if '##class(%Library.File).CopyFile(irisDAT, stagingDir _ "IRIS.DAT", 1, .result) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot copy IRIS.DAT: " _ result)) + } + } + + // Checksum the file actually in staging — the exact bytes shipped in the .tgz. + // Hashing here (not the source) closes a race where the live + // routines DB could change between hashing and copying. + write:$get(params("Verbose")) !, "Computing SHA-256 checksum..." + set checksumHex = ..ComputeSHA256Hex(stagingDir _ "IRIS.DAT") + write !, " Checksum: ", checksumHex + + // Stage non-compiled resource files so processors can find them during install + set includeTestResources = $get(params("DeveloperMode"), 0) + set exportPythonDeps = $get(params("ExportPythonDependencies"), 1) + do ..CopyNonCompiledResources(stagingDir, includeTestResources, exportPythonDeps) + kill depWheels + if exportPythonDeps { + do ..StageDependencyWheels(stagingDir, .params, .depWheels) + } + + // Bare "." is an exact-match semver expression, not a floor, so the package will + // only install on the IRIS version that built it. + set irisVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + set existingVersion = "" + if $isobject(..Module.SystemRequirements) { + set existingVersion = ..Module.SystemRequirements.VersionString + } + if (existingVersion '= "") && (existingVersion '= irisVersion) { + write !, "WARNING: Overwriting existing SystemRequirements Version '", existingVersion, "' with '", irisVersion, "'" + } + do ..WritePackageModuleXML(stagingDir, checksumHex, irisVersion) + do ..WriteDependencies(stagingDir, .depWheels) + + set outputDir = $get(params("Path")) + if outputDir = "" { + set outputDir = ##class(%Library.File).NormalizeDirectory(##class(%IPM.Utils.File).CreateTempDirectory()) + } else { + set outputDir = ##class(%Library.File).NormalizeDirectory(outputDir) + if '##class(%Library.File).DirectoryExists(outputDir) { + if '##class(%Library.File).CreateDirectoryChain(outputDir, .reason) { + $$$ThrowStatus($$$ERROR($$$GeneralError,"Cannot create output directory " _ outputDir _ ": " _ reason)) + } + } + } + + set packageFile = outputDir _ ..Module.Name _ "-" _ ..Module.VersionString _ "-database.tgz" + $$$ThrowOnError(##class(%IPM.General.Archive).Create(stagingDir, packageFile, .archiveOutput)) + + set verbose = $get(params("Verbose")) + for i=1:1:$get(archiveOutput) { + write:verbose !, archiveOutput(i) + } + write !, "Database package generated:", !, $char(9), packageFile + + quit packageFile +} + +/// Returns the staging subdirectory holding one dependency's non-compiled files. +/// Becomes that dependency's Root during the install-time dependency pass. +ClassMethod GetDependencyStagingDir( + stagingDir As %String, + depName As %String) As %String +{ + quit ##class(%Library.File).NormalizeDirectory(stagingDir _ "deps-files/" _ depName) +} + +/// Copies non-compiled resource files (FileCopy, PythonWheel, CPF) for this module and each +/// transitive dependency into stagingDir. Dependency files go under deps-files// so the +/// install-time dependency pass can point that dependency's Root at them. +/// The per-dependency directory is created even when empty: install always sets Root to it, and a +/// Root pointing at a nonexistent directory would resolve paths on the packaging machine instead. +/// WebApplication is configuration-only and has no file to stage. +Method CopyNonCompiledResources( + stagingDir As %String, + includeTestResources As %Boolean, + exportPythonDeps As %Boolean = 1) +{ + do ..StageModuleResources(..Module, stagingDir, includeTestResources, exportPythonDeps) + + set deps = ..GetDependencyModules(..Module) + set mKey = "" + for { + #dim depModule As %IPM.Storage.Module + set depModule = deps.GetNext(.mKey) + quit:mKey="" + + set depDir = ..GetDependencyStagingDir(stagingDir, depModule.Name) + set result = "" + if '##class(%Library.File).CreateDirectoryChain(depDir, .result) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot create dependency staging dir: " _ depDir _ " - " _ result)) + } + do ..StageModuleResources(depModule, depDir, includeTestResources, exportPythonDeps) + } +} + +/// Stages one module's non-compiled resource files into targetDir, mirroring the relative layout +/// the resource processors expect under a module root. +Method StageModuleResources( + module As %IPM.Storage.Module, + targetDir As %String, + includeTestResources As %Boolean, + exportPythonDeps As %Boolean = 1) +{ + set orderedResourceList = module.GetOrderedResourceList() + set key = "" + for { + #dim resource As %IPM.Storage.ResourceReference + set resource = orderedResourceList.GetNext(.key) + quit:key="" + + // Skip test-scoped resources unless requested + if 'includeTestResources && resource.IsTestScoped() { + continue + } + + if '$isobject(resource.Processor) { + continue + } + + if resource.Processor.%IsA("%IPM.ResourceProcessor.FileCopy") { + set name = resource.Processor.Name + set sourcePath = ##class(%Library.File).NormalizeFilename(name, module.Root) + set destPath = ##class(%Library.File).NormalizeFilename(name, targetDir) + do ..StageResourceFile(resource.Name, sourcePath, destPath) + + } elseif exportPythonDeps && resource.Processor.%IsA("%IPM.ResourceProcessor.PythonWheel") { + set wheelDir = resource.Processor.Directory + set wheelName = resource.Processor.Name + set sourcePath = ##class(%Library.File).NormalizeFilename(wheelName, ##class(%Library.File).NormalizeDirectory(module.Root _ wheelDir)) + set destPath = ##class(%Library.File).NormalizeDirectory(targetDir _ wheelDir) _ wheelName + do ..StageResourceFile(resource.Name, sourcePath, destPath) + + } elseif resource.Processor.%IsA("%IPM.ResourceProcessor.CPF") { + set cpfName = resource.Processor.Name + set cpfSubdir = resource.Processor.Directory + set sourcePath = ##class(%Library.File).NormalizeFilename(cpfName, ##class(%Library.File).NormalizeDirectory(module.Root _ module.SourcesRoot _ "/" _ cpfSubdir)) + set destPath = ##class(%Library.File).NormalizeDirectory(targetDir _ module.SourcesRoot _ "/" _ cpfSubdir) _ cpfName + do ..StageResourceFile(resource.Name, sourcePath, destPath) + + } + // Compiled resources (Class, Routine, Include, etc.) are handled via IRIS.DAT so no file staging needed. + // WebApplication is configuration-only so no file staging needed. + } +} + +/// Downloads each dependency's requirements.txt wheels and stages them under +/// deps-files//wheels/. Returns depWheels(depName, wheelName) = "" for each wheel that is +/// not already an explicit resource; WriteDependencies injects those into the +/// dependency's exported manifest so the install-time dependency pass installs them. +/// Injecting into the export rather than the installed .ZPM keeps the packaging machine unmodified. +Method StageDependencyWheels( + stagingDir As %String, + ByRef params, + Output depWheels) [ Private ] +{ + kill depWheels + + set deps = ..GetDependencyModules(..Module) + set mKey = "" + for { + #dim depModule As %IPM.Storage.Module + set depModule = deps.GetNext(.mKey) + quit:mKey="" + + set depRoot = ##class(%Library.File).NormalizeDirectory("", depModule.Root) + if '##class(%Library.File).Exists(##class(%Library.File).NormalizeFilename("requirements.txt", depRoot)) { + continue + } + + $$$ThrowOnError(..InstallOrDownloadPythonRequirements(depRoot, .params, 1)) + + set wheelsDir = ##class(%Library.File).NormalizeDirectory("wheels", depRoot) + if '##class(%Library.File).DirectoryExists(wheelsDir) { + write !, "WARNING: No wheels directory for dependency '", depModule.Name, "': ", wheelsDir, " not found" + continue + } + + // Wheels already declared as are staged by StageModuleResources; injecting + // them again would give the dependency two resources for one file. + kill declared + set declaredList = depModule.GetOrderedResourceList() + set rKey = "" + for { + #dim depResource As %IPM.Storage.ResourceReference + set depResource = declaredList.GetNext(.rKey) + quit:rKey="" + set declared($zconvert(depResource.Name, "L")) = "" + } + + set destWheelDir = ##class(%Library.File).NormalizeDirectory(..GetDependencyStagingDir(stagingDir, depModule.Name) _ "wheels") + + set rs = ##class(%SQL.Statement).%ExecDirect(, + "SELECT ItemName FROM %Library.File_FileSet(?, ?, ?, ?)", + wheelsDir, "*.whl", "", 0) + if rs.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error listing wheels for dependency '" _ depModule.Name _ "': " _ rs.%Message)) + } + set sc = $$$OK + while rs.%Next(.sc) { + $$$ThrowOnError(sc) + set wheelName = rs.%Get("ItemName") + continue:wheelName="" + do ..StageResourceFile( + depModule.Name _ "/" _ wheelName, + ##class(%Library.File).NormalizeFilename(wheelName, wheelsDir), + destWheelDir _ wheelName) + if '$data(declared($zconvert(wheelName, "L"))) { + set depWheels(depModule.Name, wheelName) = "" + } + } + $$$ThrowOnError(sc) + } +} + +/// Stages a resource's file, warning if it doesn't exist. A missing file would ship silently +/// and only surface at install time on the user's system. +ClassMethod StageResourceFile( + resourceName As %String, + sourcePath As %String, + destPath As %String) +{ + if '..StageFile(sourcePath, destPath) { + do ##class(%IPM.General.LogManager).Warning("Resource '" _ resourceName _ "' will be missing from this package: " _ sourcePath _ " not found") + } +} + +/// Copies a file to a destination path, creating parent directories as needed. +/// Throws on failure. Returns 1 if the file was copied, 0 if the source did not exist. +ClassMethod StageFile( + sourcePath As %String, + destPath As %String) As %Boolean +{ + if '##class(%Library.File).Exists(sourcePath) { + quit 0 + } + set destDir = ##class(%Library.File).GetDirectory(destPath) + if '##class(%Library.File).CreateDirectoryChain(destDir) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot create directory: " _ destDir)) + } + if '##class(%Library.File).CopyFile(sourcePath, destPath, 1) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot copy file: " _ sourcePath _ " to " _ destPath)) + } + quit 1 +} + +/// Exports the module manifest, applies the database packaging XSLT transform +/// (injecting Packaging, Checksum, and SystemRequirements elements), and writes to targetDir/module.xml. +Method WritePackageModuleXML( + targetDir As %String, + checksumHex As %String, + irisVersion As %String) +{ + set srcPath = ##class(%Library.File).NormalizeFilename("module.xml", ..Module.Root) + set baseStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(baseStream.LinkToFile(srcPath)) + $$$ThrowOnError(baseStream.Rewind()) + + set compiled = ..CompilePackagingTransform(checksumHex, irisVersion) + $$$ThrowOnError(##class(%XML.XSLT.Transformer).TransformStreamWithCompiledXSL(baseStream, compiled, .transformed)) + + set outPath = ##class(%Library.File).NormalizeFilename("module.xml", targetDir) + set outStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(outStream.LinkToFile(outPath)) + $$$ThrowOnError(outStream.CopyFrom(transformed)) + $$$ThrowOnError(outStream.%Save()) +} + +/// XSLT to inject database, ..., +/// and into a module XML stream. +/// REPLACECHECKSUM and REPLACEIRISVERSION are substituted with real values before XSLT compilation. +XData InjectDatabasePackagingTransform +{ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + database + + + SHA-256REPLACECHECKSUM + + + REPLACEIRISVERSION + + + + + + + + + + + +} + +/// Compiles InjectDatabasePackagingTransform with the real checksum and IRIS version substituted in. +ClassMethod CompilePackagingTransform(checksumHex As %String, irisVersion As %String) As %XML.XSLT.CompiledStyleSheet +{ + set xData = ##class(%Dictionary.CompiledXData).IDKEYOpen( + $classname(), "InjectDatabasePackagingTransform", , .sc) + $$$ThrowOnError(sc) + + set raw = "" + while 'xData.Data.AtEnd { + set raw = raw _ xData.Data.Read() + } + set raw = $replace(raw, "REPLACECHECKSUM", checksumHex) + set raw = $replace(raw, "REPLACEIRISVERSION", irisVersion) + + set stream = ##class(%Stream.TmpCharacter).%New() + $$$ThrowOnError(stream.Write(raw)) + $$$ThrowOnError(stream.Rewind()) + + $$$ThrowOnError(##class(%XML.XSLT.CompiledStyleSheet).CreateFromStream(stream, .compiled)) + quit compiled +} + +/// Writes each dependency's IPM metadata to deps/.xml as a standalone IRIS export, +/// loadable at install time with $system.OBJ.Load. +/// depWheels(depName, wheelName) entries are injected as elements into that +/// dependency's exported manifest. +Method WriteDependencies( + targetDir As %String, + ByRef depWheels) +{ + set depsDir = ##class(%Library.File).NormalizeDirectory( + ##class(%Library.File).NormalizeFilename("deps", targetDir)) + if '##class(%Library.File).CreateDirectoryChain(depsDir) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot create deps directory: " _ depsDir)) + } + + set depNames = ..GetDependencyNames(..Module) + set ptr = 0 + while $listnext(depNames, ptr, depName) { + set depFile = ##class(%Library.File).NormalizeFilename(depName _ ".xml", depsDir) + set fileStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(fileStream.LinkToFile(depFile)) + set exportStream = ##class(%Stream.TmpCharacter).%New() + $$$ThrowOnError($system.OBJ.ExportToStream(depName _ ".ZPM", .exportStream, "-d")) + + set wheelName = "" + for { + set wheelName = $order(depWheels(depName, wheelName)) + quit:wheelName="" + $$$ThrowOnError(exportStream.Rewind()) + set transform = ##class(%IPM.StudioDocument.Module).CompileAppendWheelUnderModuleXSLT(wheelName) + $$$ThrowOnError(##class(%XML.XSLT.Transformer).TransformStreamWithCompiledXSL(exportStream, transform, .injected)) + set exportStream = injected + } + + $$$ThrowOnError(exportStream.Rewind()) + $$$ThrowOnError(fileStream.CopyFrom(exportStream)) + $$$ThrowOnError(fileStream.%Save()) + } +} + +/// Recursively builds depMap(lowercased name) = module oref (or "" if not installed). +/// Sets the entry before recursing so circular dependencies terminate. +ClassMethod CollectAllDependencies( + module As %IPM.Storage.Module, + ByRef depMap) +{ + for i=1:1:module.Dependencies.Count() { + set depName = module.Dependencies.GetAt(i).Name + set depKey = $zconvert(depName, "L") + if $data(depMap(depKey)) { + continue + } + if '##class(%IPM.Storage.Module).NameExists(depName) { + set depMap(depKey) = "" + continue + } + set depModule = ##class(%IPM.Storage.Module).NameOpen(depName, , .sc) + $$$ThrowOnError(sc) + set depMap(depKey) = depModule + do ..CollectAllDependencies(depModule, .depMap) + } +} + +/// Returns installed transitive dependency modules in name order. +ClassMethod GetDependencyModules(module As %IPM.Storage.Module) As %ListOfObjects +{ + do ..CollectAllDependencies(module, .depMap) + set deps = ##class(%ListOfObjects).%New() + set depKey = "" + for { + set depKey = $order(depMap(depKey), 1, depModule) + quit:depKey="" + continue:'$isobject(depModule) + do deps.Insert(depModule) + } + quit deps +} + +/// Returns this module followed by its installed transitive dependencies. +/// The root comes first because every caller needs its resources considered before its dependencies'. +Method GetSelfAndDependencyModules() As %ListOfObjects [ Private ] +{ + set modules = ..GetDependencyModules(..Module) + do modules.InsertAt(..Module, 1) + quit modules +} + +/// Returns the installed transitive dependency names in name order. +ClassMethod GetDependencyNames(module As %IPM.Storage.Module) As %List +{ + set deps = ..GetDependencyModules(module) + set names = "" + for i=1:1:deps.Count() { + set names = names _ $listbuild(deps.GetAt(i).Name) + } + quit names +} + +/// Returns installed transitive dependency names with dependencies before dependents. +/// Unlike GetDependencyNames (name order), this ordering is required when running lifecycle phases: +/// a dependency's phase may rely on a deeper dependency's files already being in place. +/// Resolves entirely from locally registered metadata — no repository access. +ClassMethod GetTopologicalDependencyNames(module As %IPM.Storage.Module) As %List +{ + kill visited + set ordered = "" + do ..VisitDependencies(module, .visited, .ordered) + quit ordered +} + +/// Depth-first post-order helper for GetTopologicalDependencyNames. +/// Marks each module visited before recursing so dependency cycles terminate. +ClassMethod VisitDependencies( + module As %IPM.Storage.Module, + ByRef visited, + ByRef ordered) [ Private ] +{ + for i=1:1:module.Dependencies.Count() { + set depName = module.Dependencies.GetAt(i).Name + if $data(visited($zconvert(depName, "L"))) { + continue + } + set visited($zconvert(depName, "L")) = "" + if '##class(%IPM.Storage.Module).NameExists(depName) { + continue + } + set depModule = ##class(%IPM.Storage.Module).NameOpen(depName, , .sc) + $$$ThrowOnError(sc) + do ..VisitDependencies(depModule, .visited, .ordered) + set ordered = ordered _ $listbuild(depModule.Name) + } +} + +// ===================================================================== +// Install helpers +// ===================================================================== + +/// Returns 1 if the module is database-packaged and IRIS.DAT is present in the module root. +/// Both conditions are required: Packaging alone could mean a source load with a Database lifecycle. +Method IsInstallContext() As %Boolean [ Private ] +{ + set irisDAT = ##class(%Library.File).NormalizeFilename("IRIS.DAT", ..Module.Root) + quit (..Module.Packaging = "database") && ##class(%Library.File).Exists(irisDAT) +} + +/// Returns 1 if the module.xml in dir was produced by database packaging. +/// Detected by the element InjectDatabasePackagingTransform adds. Checksum is not a +/// property of %IPM.Storage.Module, so it appears only in packaged manifests and never in one +/// written by hand. Reads the file rather than the installed record: a first install whose +/// metadata was rolled back leaves no record to consult. +ClassMethod IsPackagedManifest(dir As %String) As %Boolean +{ + if dir = "" { + quit 0 + } + set moduleXML = ##class(%Library.File).NormalizeFilename("module.xml", dir) + if '##class(%Library.File).Exists(moduleXML) { + quit 0 + } + set xmlStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(xmlStream.LinkToFile(moduleXML)) + // A file that does not parse is not a packaged manifest; let the source path report on it. + if $$$ISERR(##class(%XML.XPATH.Document).CreateFromStream(xmlStream, .xpathDoc)) { + quit 0 + } + $$$ThrowOnError(xpathDoc.EvaluateExpression("/Export/Document/Module/Checksum", "text()", .results)) + quit (results.Count() > 0) +} + +/// Pre-install validation. Throws on any failure; does not modify state. +/// staleModules (output): modules the previous version carried that the incoming one drops. +Method ValidateBeforeSwap( + packageDir As %String, + ByRef params, + Output staleModules As %List) [ Private ] +{ + set staleModules = "" + set irisDAT = ##class(%Library.File).NormalizeFilename("IRIS.DAT", packageDir) + if '##class(%Library.File).Exists(irisDAT) { + set msg = "Database package is missing IRIS.DAT in: " _ packageDir + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } + set moduleXML = ##class(%Library.File).NormalizeFilename("module.xml", packageDir) + if '##class(%Library.File).Exists(moduleXML) { + set msg = "Database package is missing module.xml in: " _ packageDir + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } + + set xmlStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(xmlStream.LinkToFile(moduleXML)) + $$$ThrowOnError(##class(%XML.XPATH.Document).CreateFromStream(xmlStream, .xpathDoc)) + + $$$ThrowOnError(xpathDoc.EvaluateExpression("/Export/Document/Module/Packaging", "text()", .packagingResults)) + set packagingValue = $select(packagingResults.Count() > 0: packagingResults.GetAt(1).Value, 1: "") + if packagingValue '= "database" { + set msg = "module.xml does not declare database. Got: " _ packagingValue + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } + + set depsDir = ##class(%Library.File).NormalizeDirectory( + ##class(%Library.File).NormalizeFilename("deps", packageDir)) + if '##class(%Library.File).DirectoryExists(depsDir) { + set msg = "Database package is missing deps/ directory in: " _ packageDir + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } + + $$$ThrowOnError(xpathDoc.EvaluateExpression("/Export/Document/Module/Checksum", "text()", .checksumResults)) + if checksumResults.Count() = 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "module.xml is missing element.")) + } + + // Only one database package per namespace is allowed + set conflictRS = ##class(%SQL.Statement).%ExecDirect(, + "SELECT Name FROM %IPM_Storage.ModuleItem WHERE Packaging = 'database' AND Name != ?", + ..Module.Name) + if conflictRS.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error checking for conflicting database packages: " _ conflictRS.%Message)) + } + if conflictRS.%Next() { + $$$ThrowStatus($$$ERROR($$$GeneralError, "A database package ('" _ conflictRS.%Get("Name") _ "') is already installed in this namespace. Only one database package per namespace is allowed.")) + } + + do ..ValidateNoOrphanedModules(depsDir, $get(params("PreviousDependencies")), .staleModules) + + // Validate IPM is not in the target namespace's routines DB + do ..ValidateIPMNotInRoutinesDB() + do ..ValidateRoutinesDatabaseNotMirrored($namespace) + do ..ValidateRoutinesDatabaseNotShared($namespace) + + // Hashing reads the whole IRIS.DAT, so it runs last: every cheaper reason to reject should + // already have reported instead of making the user wait on a multi-gigabyte read. + set expectedChecksum = checksumResults.GetAt(1).Value + set actualChecksum = ..ComputeSHA256Hex(irisDAT) + if $zconvert(actualChecksum, "L") '= $zconvert(expectedChecksum, "L") { + set msg = "IRIS.DAT SHA-256 mismatch. Expected: " _ expectedChecksum _ " Actual: " _ actualChecksum + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } +} + +/// Throws if any installed module has code in the routines database that the swap would destroy. +/// Exempt: modules in this package, modules whose code is mapped from elsewhere, modules with no +/// code, and modules the previous version carried but the incoming one drops (their code leaves with +/// the old DAT). The last group is returned in staleModules for the caller to unconfigure and remove. +/// previousDeps: installed version's dependency names, captured before its manifest was overwritten. +Method ValidateNoOrphanedModules( + depsDir As %String, + previousDeps As %List = "", + Output staleModules As %List) [ Private ] +{ + set staleModules = "" + set packagedNames = ..GetPackagedDependencyNames(depsDir) + + // Module names are stored as declared, so normalize the captured set for comparison. + set previousNames = "" + set ptr = 0 + while $listnext(previousDeps, ptr, prevName) { + set previousNames = previousNames _ $listbuild($zconvert(prevName, "L")) + } + + set rs = ##class(%SQL.Statement).%ExecDirect(, + "SELECT Name FROM %IPM_Storage.ModuleItem WHERE Name != ?", + ..Module.Name) + if rs.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error listing installed modules: " _ rs.%Message)) + } + + set orphaned = "" + while rs.%Next() { + set moduleName = rs.%Get("Name") + if $listfind(packagedNames, $zconvert(moduleName, "L")) { + continue + } + // Carried by the outgoing version but not the incoming one: the swap replaces its code + // rather than orphaning it, so the record is stale and must be removed, not protected. + if $listfind(previousNames, $zconvert(moduleName, "L")) { + set staleModules = staleModules _ $listbuild(moduleName) + continue + } + set installedModule = ##class(%IPM.Storage.Module).NameOpen(moduleName, , .sc) + $$$ThrowOnError(sc) + if ..HasCodeInLocalRoutinesDB(installedModule) { + set orphaned = orphaned _ $listbuild(moduleName) + } + } + if orphaned = "" { + quit + } + + set plural = ($listlength(orphaned) > 1) + set msg = "Installing this database package would replace the routines database for namespace " _ $namespace + set msg = msg _ ", destroying the code of " _ $select(plural: "these already-installed modules", 1: "this already-installed module") + set msg = msg _ ": " _ $listtostring(orphaned, ", ") + set msg = msg _ ". Uninstall " _ $select(plural: "them", 1: "it") _ " first, or run 'uninstall -all -force' to clear the namespace." + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) +} + +/// Removes records of modules the previous package version carried but the incoming one drops. +/// Deletes directly rather than running Clean: Clean deletes by resource name, which after the swap +/// would also delete same-named resources belonging to the newly installed package. +ClassMethod RemoveStaleModuleMetadata(staleModules As %List = "") +{ + set ptr = 0 + while $listnext(staleModules, ptr, moduleName) { + continue:moduleName="" + if '##class(%IPM.Storage.Module).NameExists(moduleName) { + continue + } + // Deletes the ModuleStream alongside the Module record; deleting the record alone would + // leave an orphaned .ZPM studio document behind. + $$$ThrowOnError(##class(%IPM.StudioDocument.Module).Delete(moduleName _ ".ZPM")) + write !, "Removed module no longer carried by this package: ", moduleName + } +} + +/// Runs the Unconfigure phase for each module the incoming package drops, so the parts of their +/// installation that live outside the routines database (web applications, copied files, installer +/// class side effects) are undone rather than orphaned by the swap. +/// +/// Must be called before the swap, while these modules' own code is still mounted and runnable. +/// Failures are reported and skipped: a module that cannot unconfigure cleanly must not abort an +/// install that has already been confirmed, and the swap will remove its code regardless. +ClassMethod UnconfigureStaleModules( + staleModules As %List = "", + ByRef params) +{ + set ptr = 0 + while $listnext(staleModules, ptr, moduleName) { + continue:moduleName="" + if '##class(%IPM.Storage.Module).NameExists(moduleName) { + continue + } + // Run the phase rather than calling %Unconfigure directly: resource processors do their + // teardown in OnBeforePhase("Unconfigure"), which only ExecutePhases invokes. + // Only Verbose carries over — this module's install params (root directory, forced + // lifecycle class, install context) all describe the incoming package, not this module. + kill unconfigureParams + set unconfigureParams("Verbose") = $get(params("Verbose")) + set sc = ##class(%IPM.Storage.Module).ExecutePhases(moduleName, $listbuild("Unconfigure"), 0, .unconfigureParams) + if $$$ISERR(sc) { + write !, "WARNING: could not unconfigure ", moduleName, ": ", $system.Status.GetErrorText(sc) + } + } +} + +/// Returns lowercased names of dependencies carried in deps/ (one .xml per dependency). +ClassMethod GetPackagedDependencyNames(depsDir As %String) As %List +{ + set names = "" + set rs = ##class(%SQL.Statement).%ExecDirect(, + "SELECT ItemName FROM %Library.File_FileSet(?, ?, ?, ?)", + depsDir, "*.xml", "", 0) + if rs.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error listing deps: " _ rs.%Message)) + } + while rs.%Next() { + set itemName = rs.%Get("ItemName") + continue:itemName="" + set names = names _ $listbuild($zconvert($piece(itemName, ".", 1, *-1), "L")) + } + quit names +} + +/// Returns 1 if any of the module's code resources exist locally in the routines database +/// (not mapped from elsewhere). Only CLS/PKG/MAC/INT/INC are checked; other types don't live there. +ClassMethod HasCodeInLocalRoutinesDB(module As %IPM.Storage.Module) As %Boolean +{ + set found = 0 + set orderedResourceList = module.GetOrderedResourceList() + set key = "" + for { + #dim resource As %IPM.Storage.ResourceReference + set resource = orderedResourceList.GetNext(.key) + quit:key="" + + if '$listfind($listbuild("CLS", "PKG", "MAC", "INT", "INC"), $zconvert($piece(resource.Name, ".", *), "U")) { + continue + } + + kill children + $$$ThrowOnError(resource.ResolveChildren(.children)) + set docName = "" + for { + set docName = $order(children(docName)) + quit:docName="" + continue:'##class(%Library.RoutineMgr).Exists(docName) + if '##class(%Library.RoutineMgr).IsMapped(docName) { + set found = 1 + quit + } + } + quit:found + } + quit found +} + +/// Shows swap details and prompts for y/N confirmation. Throws if the user declines. +Method PromptForSwapConfirmation( + ns As %String, + staleModules As %List = "") [ Private ] +{ + set routinesDBName = ..GetRoutinesDBName(ns) + set dbDir = ..GetDatabaseDirectory(routinesDBName) + + write !, "WARNING: This will replace the routines database for namespace ", ns + write !, " Current database: ", dbDir _ "IRIS.DAT" + write !, " The current IRIS.DAT will be renamed to a timestamped backup before the swap." + if staleModules '= "" { + write !, " These modules are no longer part of this package and will be removed: ", $listtostring(staleModules, ", ") + } + write ! + + set confirmed = 0 + set response = ##class(%Library.Prompt).GetYesNo("Proceed with database swap? [y/N] ", .confirmed) + if (response '= $$$SuccessResponse) || 'confirmed { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Database swap cancelled by user.")) + } +} + +/// Dismounts the current routines DB, renames IRIS.DAT to a timestamped backup, moves the new one +/// in, and remounts. The namespace is briefly without a routines DB; it should be idle during swap. +/// dbDir and backupPath (output) are used by RollbackDatabaseSwap on failure. +Method PerformDatabaseSwap( + packageDir As %String, + ns As %String, + Output dbDir As %String, + Output backupPath As %String) [ Private ] +{ + set dbDir = "" + set backupPath = "" + + new $namespace + set $namespace = "%SYS" + + set routinesDBName = ..GetRoutinesDBName(ns) + set dbDir = ..GetDatabaseDirectory(routinesDBName) + + $$$ThrowOnError(##class(SYS.Database).DismountDatabase(dbDir)) + + // Rename current IRIS.DAT to timestamped backup: IRIS__YYYYMMDDHHmmss.DAT. + // Timestamp is second-resolution, so append a counter suffix if that name already exists + set h = $horolog + set timestamp = $translate($zdate(h, 8), "-", "") _ $translate($ztime($piece(h, ",", 2), 2), ":", "") + set backupBase = dbDir _ "IRIS_" _ routinesDBName _ "_" _ timestamp + set backupPath = backupBase _ ".DAT" + set suffix = 0 + while ##class(%Library.File).Exists(backupPath) { + set suffix = suffix + 1 + set backupPath = backupBase _ "_" _ suffix _ ".DAT" + } + if '##class(%Library.File).Rename(dbDir _ "IRIS.DAT", backupPath) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Failed to rename IRIS.DAT to backup: " _ backupPath)) + } + + // Move (rename) packaged IRIS.DAT into the database directory + set srcDAT = ##class(%Library.File).NormalizeFilename("IRIS.DAT", packageDir) + if '##class(%Library.File).Rename(srcDAT, dbDir _ "IRIS.DAT") { + set tMsg = "Failed to move IRIS.DAT from " _ srcDAT _ " to " _ dbDir + $$$ThrowStatus($$$ERROR($$$GeneralError, tMsg)) + } + + $$$ThrowOnError(##class(SYS.Database).MountDatabase(dbDir)) +} + +/// Loads deps/ export files to register dependency metadata in IPM storage. +/// The dependency code is already in the swapped-in DB; this only makes it visible to `zpm list`. +ClassMethod RegisterDependencyMetadata(packageDir As %String) +{ + set depsDir = ##class(%Library.File).NormalizeDirectory( + ##class(%Library.File).NormalizeFilename("deps", packageDir)) + if '##class(%Library.File).DirectoryExists(depsDir) { + quit + } + + // Each file in deps/ is a standalone IRIS export XML, one per dependency. + // Load them directly — no XML parsing required. + set rs = ##class(%SQL.Statement).%ExecDirect(, + "SELECT Name FROM %Library.File_FileSet(?, ?, ?, ?)", + depsDir, "*.xml", "", 0) + if rs.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error listing deps: " _ rs.%Message)) + } + while rs.%Next() { + $$$ThrowOnError($system.OBJ.Load(rs.%Get("Name"), "ck")) + } +} + +/// Phases a dependency must not run during a database install, with the reason for each. +/// Reload: no source ships for a dependency, and Base.%Reload early-exits without a root. +/// Compile: the code is already compiled into IRIS.DAT. +/// "*": the module-refresh checkpoint, meaningful only for the module being installed. +/// Edit here — not in GetDependencyInstallPhases — when this judgment changes. +ClassMethod GetDependencyExcludedPhases() As %List +{ + quit $listbuild("Reload", "Compile", "*") +} + +/// Derives the phases each dependency runs from the canonical install chain, minus the exclusions. +/// Derived rather than hardcoded so a phase added to GetCompletePhasesForOne is not silently +/// skipped for dependencies; TestDependencyPhasesCoverCanonicalChain fails if a new canonical phase +/// is neither run nor explicitly excluded. +ClassMethod GetDependencyInstallPhases(isUpdate As %Boolean = 0) As %List +{ + // Mirrors the entry point the install itself picks (%IPM.Utils.Module:LoadNewModule). + set entryPhase = $select(isUpdate: "ApplyUpdateSteps", 1: "Activate") + set canonical = ##class(%IPM.Lifecycle.Base).GetCompletePhasesForOne(entryPhase) + set excluded = ..GetDependencyExcludedPhases() + + set phases = "" + set ptr = 0 + while $listnext(canonical, ptr, onePhase) { + continue:onePhase="" + continue:$listfind(excluded, onePhase) + set phases = phases _ $listbuild(onePhase) + } + quit phases +} + +/// Runs each transitive dependency's install phases after the swap, so dependency-owned +/// non-compiled resources (FileCopy, CPF, PythonWheel, WebApplication) and elements are +/// applied rather than silently dropped. +/// Topological order: a dependency's phases may rely on a deeper dependency's files. +/// Each dependency's Root points at its deps-files// staging directory inside the package. +/// depInitClasses (output): processor class names a dependency used at Initialize. The root's +/// Initialize already ran before the swap, so ReassertRootInitializeResources uses this to +/// re-apply only the affected root resources. +Method ApplyDependencyResources( + packageDir As %String, + ByRef params, + Output depInitClasses) [ Private ] +{ + kill depInitClasses + + set depNames = ..GetTopologicalDependencyNames(..Module) + if depNames = "" { + quit + } + + set verbose = $get(params("Verbose")) + set isUpdate = $get(params("Update"), 0) + set depPhases = ..GetDependencyInstallPhases(isUpdate) + set ptr = 0 + while $listnext(depNames, ptr, depName) { + continue:depName="" + if '##class(%IPM.Storage.Module).NameExists(depName) { + continue + } + + set depModule = ##class(%IPM.Storage.Module).NameOpen(depName, , .sc) + $$$ThrowOnError(sc) + do ..CollectInitializePhaseClasses(depModule, .depInitClasses) + + write:verbose !, "Applying resources for dependency ", depName + + // Only Verbose, cmd and Update carry over: the root's remaining install params (install + // context, root directory, swap flag, previous dependency set) describe the incoming + // package. Update decides whether Activate seeds update steps or ApplyUpdateSteps runs them. + kill depParams + set depParams("Verbose") = verbose + set depParams("cmd") = $get(params("cmd")) + set depParams("Update") = isUpdate + set depParams("RootDirectory") = ..GetDependencyStagingDir(packageDir, depName) + + set phasePtr = 0 + while $listnext(depPhases, phasePtr, depPhase) { + // isComplete = 0: expanding the chain again would reintroduce the excluded phases. + set sc = ##class(%IPM.Storage.Module).ExecutePhases(depName, $listbuild(depPhase), 0, .depParams) + if $$$ISERR(sc) { + write !, "ERROR: dependency '", depName, "' failed during ", depPhase, ": ", $system.Status.GetErrorText(sc) + $$$ThrowStatus(sc) + } + } + } +} + +/// Re-applies the root module's Initialize-phase resources after the dependency pass. +/// The root's Initialize completed before the swap, so a dependency's Initialize resources land +/// afterwards and take precedence. Re-asserting restores the root's intended precedence. +/// Scoped to processor classes a dependency actually used at Initialize: with no dependency CPF or +/// wheel there is nothing to correct, and a CPF merge costs a subprocess call. Both affected +/// processors are safe to repeat — a merge applies file values, a present wheel is a no-op. +Method ReassertRootInitializeResources( + ByRef depInitClasses, + ByRef params) [ Private ] +{ + if '$data(depInitClasses) { + quit + } + + kill rootInitClasses + do ..CollectInitializePhaseClasses(..Module, .rootInitClasses) + + set orderedResourceList = ..Module.GetOrderedResourceList() + set key = "" + for { + #dim resource As %IPM.Storage.ResourceReference + set resource = orderedResourceList.GetNext(.key) + quit:key="" + continue:'$isobject(resource.Processor) + + do resource.Processor.SetParams(.params) + + if resource.Processor.%IsA("%IPM.ResourceProcessor.CPF") && $data(depInitClasses("%IPM.ResourceProcessor.CPF")) && $data(rootInitClasses("%IPM.ResourceProcessor.CPF")) { + if (resource.Processor.CustomPhase '= "") || (resource.Processor.Phase '= "Initialize") { + continue + } + write !, "Re-applying CPF merge '", resource.Name, "' — a dependency merged a CPF after this module's Initialize phase." + if resource.Processor.When = "After" { + $$$ThrowOnError(resource.Processor.OnAfterPhase("Initialize", .params)) + } else { + $$$ThrowOnError(resource.Processor.OnBeforePhase("Initialize", .params)) + } + + } elseif resource.Processor.%IsA("%IPM.ResourceProcessor.PythonWheel") && $data(depInitClasses("%IPM.ResourceProcessor.PythonWheel")) { + write !, "Re-installing Python wheel '", resource.Name, "' — a dependency installed a wheel after this module's Initialize phase." + set handled = 0 + $$$ThrowOnError(resource.Processor.OnPhase("Initialize", .params, .handled)) + } + } +} + +/// Records the processor class of each resource that acts during the Initialize phase. +/// CPF is phase-configurable, so only count it when it actually targets Initialize. +ClassMethod CollectInitializePhaseClasses( + module As %IPM.Storage.Module, + ByRef initClasses) +{ + set orderedResourceList = module.GetOrderedResourceList() + set key = "" + for { + #dim resource As %IPM.Storage.ResourceReference + set resource = orderedResourceList.GetNext(.key) + quit:key="" + continue:'$isobject(resource.Processor) + + if resource.Processor.%IsA("%IPM.ResourceProcessor.PythonWheel") { + set initClasses("%IPM.ResourceProcessor.PythonWheel") = "" + } elseif resource.Processor.%IsA("%IPM.ResourceProcessor.CPF") { + if (resource.Processor.CustomPhase = "") && (resource.Processor.Phase = "Initialize") { + set initClasses("%IPM.ResourceProcessor.CPF") = "" + } + } + } +} + +/// Reports a failure that happened after the swap already succeeded, without rolling back. +/// A rollback here would discard a working swap and could not reverse what the post-swap steps +/// already did (dependency CPF merges, installed wheels, copied files), leaving old code paired +/// with new configuration. These failures are normally fixable forward and the install re-run. +Method ReportPostSwapFailure( + sc As %Status, + ns As %String, + backupPath As %String) [ Private ] +{ + write !, "ERROR: the database swap succeeded but a later step failed: ", $system.Status.GetErrorText(sc) + write !, " Namespace ", ns, " is running on the newly installed IRIS.DAT. It was NOT rolled back." + write !, " Rolling back would discard a successful swap and could not undo the side effects of the steps" + write !, " that already ran (CPF merges, installed Python wheels, copied files, web applications)." + // Reported only with a transaction open: under -DNoTransaction or -DNoJournal the metadata + // is already committed, so there is no split to warn about. + if $tlevel > 0 { + write !, " Module metadata is in globals and will be rolled back; the swapped IRIS.DAT is a file" + write !, " rename and stays. 'list' will then report the previous version, whose code has been" + write !, " replaced by the newly installed code." + write !, " Fix the error reported above, then re-run the same install to commit the metadata against" + write !, " the already-mounted code. Do not uninstall first: it would delete resources by the" + write !, " previous version's names, which are not what is now mounted." + } + if backupPath '= "" { + write !, " To revert manually instead, restore the previous database from: ", backupPath + } +} + +/// Best-effort rollback after a failed swap. Never throws; all failures are logged. +/// Restores the backup IRIS.DAT if one exists; otherwise leaves the original in place. +Method RollbackDatabaseSwap( + dbDir As %String, + backupPath As %String) +{ + if dbDir = "" { + quit + } + + new $namespace + set $namespace = "%SYS" + + set sc = ##class(SYS.Database).DismountDatabase(dbDir) + if $$$ISERR(sc) { + write !, "Rollback warning — could not dismount database at ", dbDir + } + + // Only touch dbDir/IRIS.DAT when a backup exists to restore. If no backup exists, + // the swap failed before the original was renamed out — the file still in place is + // the original routines DB and must not be deleted. + if (backupPath '= "") && ##class(%Library.File).Exists(backupPath) { + set newDAT = dbDir _ "IRIS.DAT" + if ##class(%Library.File).Exists(newDAT) { + if '##class(%Library.File).Delete(newDAT) { + write !, "Rollback warning — could not delete swapped-in IRIS.DAT at ", newDAT + } + } + if '##class(%Library.File).Rename(backupPath, dbDir _ "IRIS.DAT") { + write !, "Rollback warning — could not restore backup from ", backupPath + write !, " Manual intervention required to restore IRIS.DAT." + } + } + + set mountSC = ##class(SYS.Database).MountDatabase(dbDir) + if $$$ISERR(mountSC) && '$system.Status.Equals(mountSC, $$$ERRORCODE($$$AlreadyMounted)) { + write !, "Rollback warning — could not remount database at ", dbDir + write !, " Namespace routines may be unavailable — manual remount required." + } +} + +// ===================================================================== +// Database utilities +// ===================================================================== + +/// Returns the normalized directory path for a database. +/// Falls back to / for unregistered databases. +ClassMethod GetDatabaseDirectory(dbName As %String) As %String +{ + new $namespace + set $namespace = "%SYS" + if ##class(Config.Databases).Exists(dbName) { + $$$ThrowOnError(##class(Config.Databases).Get(dbName, .dbProps)) + quit ##class(%Library.File).NormalizeDirectory($get(dbProps("Directory"))) + } + quit ##class(%Library.File).NormalizeDirectory( + ##class(%Library.File).ManagerDirectory() _ dbName) +} + +/// Returns the Config.Databases key for a namespace's routines database. +ClassMethod GetRoutinesDBName(ns As %String) As %String +{ + new $namespace + set $namespace = "%SYS" + $$$ThrowOnError(##class(Config.Namespaces).Get(ns, .nsProps)) + quit $get(nsProps("Routines")) +} + +} diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls index 012a5bed9..aa59e140c 100644 --- a/src/cls/IPM/Main.cls +++ b/src/cls/IPM/Main.cls @@ -133,7 +133,8 @@ This command is an alias for `module-action module-name test` -This command is an alias for `module-action module-name package` +This command is an alias for `module-action module-name package`. +Packages the module according to its Packaging property, which defaults to source packaging (a .tgz of the module's source files). Equivalent to `package-source` unless the module specifies a different Packaging type. @@ -142,6 +143,38 @@ This command is an alias for `module-action module-name package` + + +Creates a .tgz archive of module source files. Equivalent to the existing `package` command. + + + + + + + + + +Creates an IRIS.DAT database package bundled in a .tgz containing IRIS.DAT, module.xml (with SHA-256 checksum), and a deps/ directory of dependency manifests. +By default, creates a fresh empty database and loads this module's own resources plus all transitive dependency code into it, producing a self-contained IRIS.DAT. Use -use-current-db to package the namespace's entire routines database as-is, which includes all compiled code from all installed modules. + + + + + + + + + + + + +Creates a Studio project package. Deprecated: will be removed in IPM 1.0.0. Equivalent to using `package` on a module with Packaging=studio-project. + + + + + This command is an alias for `module-action module-name verify` @@ -160,6 +193,20 @@ This command is an alias for `module-action module-name publish` + + +Packages the module as an IRIS.DAT database package and publishes it to a registry in one step. +Equivalent to running `package-database` followed by `publish`, but without needing a separate output path. + + + + + + + + + + Updates a module to a newer version. @@ -182,6 +229,7 @@ This command is an alias for `module-action module-name publish` + @@ -310,6 +358,7 @@ load C:\module\root\path -env C:\path\to\env1.json;C:\path\to\env2.json + @@ -437,6 +486,7 @@ install -env /path/to/env1.json;/path/to/env2.json example-package + @@ -1091,6 +1141,23 @@ ClassMethod ShellInternal( do ..Unpublish(.tCommandInfo) } elseif (tCommandInfo = "update") { do ..Update(.tCommandInfo) + } elseif (tCommandInfo = "package-source") { + // Identical to "package": route to the Package phase using current lifecycle + set tCommandInfo = "package" + do ..RunOnePhase(.tCommandInfo) + } elseif (tCommandInfo = "package-database") || (tCommandInfo = "publish-database") { + // Fail fast before any work begins — same check as %IPM.Lifecycle.Database:%Package + do ##class(%IPM.Lifecycle.Database).ValidateIPMNotInRoutinesDB() + // Force the Database lifecycle class; publish-database packages as part of publishing + set targetPhase = $select(tCommandInfo = "publish-database": "publish", 1: "package") + set tCommandInfo("data","Lifecycle") = "%IPM.Lifecycle.Database" + set tCommandInfo = targetPhase + do ..RunOnePhase(.tCommandInfo) + } elseif (tCommandInfo = "package-studio-project") { + // Force the StudioProject lifecycle class and route to the Package phase + set tCommandInfo("data","Lifecycle") = "%IPM.Lifecycle.StudioProject" + set tCommandInfo = "package" + do ..RunOnePhase(.tCommandInfo) } elseif ($listfind(..#STANDARDPHASES,tCommandInfo)) { do ..RunOnePhase(.tCommandInfo) } elseif (tCommandInfo = "generate") { @@ -2543,8 +2610,24 @@ ClassMethod Install( set tSearchCriteria.Name = $$$lcase(tModuleName) set tSearchCriteria.VersionExpression = tVersion set tSearchCriteria.Keywords = tKeywords + if ($get(pCommandInfo("data","SwapDB"))) { + set tSearchCriteria.Packaging = "database" + } else { + set tSearchCriteria.Packaging = "source" + } $$$ThrowOnError(##class(%IPM.Repo.Utils).SearchRepositoriesForModule(tSearchCriteria,.tResults)) + // If no source package found, check whether a database-only package exists and give a helpful error + if (tResults.Count() = 0) && (tSearchCriteria.Packaging = "source") { + // Clone so the probe differs only in packaging + set checkCriteria = tSearchCriteria.%ConstructClone() + set checkCriteria.Packaging = "database" + $$$ThrowOnError(##class(%IPM.Repo.Utils).SearchRepositoriesForModule(checkCriteria,.fallbackResults)) + if (fallbackResults.Count() > 0) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Module '"_tModuleName_"' is only available as a database package. Re-run with -swap-db to install it.")) + } + } + if (tResults.Count() > 0) { set tResult = "" #dim tResult As %IPM.Storage.QualifiedModuleInfo diff --git a/src/cls/IPM/Repo/Oras/ArtifactMetadata.cls b/src/cls/IPM/Repo/Oras/ArtifactMetadata.cls index 7cb1aa027..18315a64a 100644 --- a/src/cls/IPM/Repo/Oras/ArtifactMetadata.cls +++ b/src/cls/IPM/Repo/Oras/ArtifactMetadata.cls @@ -22,4 +22,6 @@ Property IPMModuleV1XML As %IPM.DataType.LongString(%JSONFIELDNAME = "com.inters Property IPMPlatformVersion As %String(%JSONFIELDNAME = "com.intersystems.ipm.platformVersion"); +Property IPMPackaging As %String(%JSONFIELDNAME = "com.intersystems.ipm.packaging"); + } diff --git a/src/cls/IPM/Repo/Oras/PackageService.cls b/src/cls/IPM/Repo/Oras/PackageService.cls index 058d6562c..c82e25ecc 100644 --- a/src/cls/IPM/Repo/Oras/PackageService.cls +++ b/src/cls/IPM/Repo/Oras/PackageService.cls @@ -152,7 +152,9 @@ Method GetModule( Output AsArchive As %Boolean = 0) As %Stream.Object { set tag = $$$Semver2Tag(pModuleReference.VersionString) - if (pModuleReference.Deployed) { + if (pModuleReference.IPMPackaging = "database") { + set tag = tag _ "_database" _ $$$OrasTagPlatformSeparator _ pModuleReference.PlatformVersion + } elseif (pModuleReference.Deployed) { set tag = tag _ $$$OrasTagPlatformSeparator _ pModuleReference.PlatformVersion } set status = ..Pull(..Location, pModuleReference.Name, tag, ..Namespace, ..Username, ..Password, ..Token, ..TokenAuthMethod, .stream) @@ -247,6 +249,10 @@ Method ListModulesFromTagString( // get metadata from annotations set metadata = ..GetPackageMetadata(..Location, name, tag, "", client) + if (metadata = "") { + // Tag exists in the tag list but manifest is unreachable — skip silently + continue + } set artifactMetadata = ##class(%IPM.Repo.Oras.ArtifactMetadata).%New() $$$ThrowOnError(artifactMetadata.%JSONImport(metadata)) @@ -292,15 +298,31 @@ Method ListModulesFromTagString( } } + // Determine packaging type from annotation; absent means source for backwards compatibility + set modPackaging = $case(artifactMetadata.IPMPackaging, "database":"database", :"source") + + // Apply packaging filter if specified + if (searchCriteria.Packaging '= "") && (modPackaging '= searchCriteria.Packaging) { + continue + } + set tModRef = ##class(%IPM.Storage.ModuleInfo).%New() // `artifactMetadata.ImageTitle` can be different from `name`. E.g., when the module was simply "moved" from elsewhere under a different name. set tModRef.Name = name // `artifactMetadata.ImageVersion` can be different from `moduleVersion`. E.g., when the module was simply "moved" from elsewhere under a different tag - set tModRef.VersionString = tVersion.ToString() + // For database packages, VersionString must be the plain semver (e.g. "1.0.0"), not "1.0.0+database". + // GetModule constructs the full tag ("1.0.0_database__") from IPMPackaging + PlatformVersion. + // Using tVersion.ToString() for database packages would embed "+database" in VersionString, + // causing GetModule to build "1.0.0_database_database__". + set tModRef.VersionString = $select(modPackaging = "database": tVersion.Major _ "." _ tVersion.Minor _ "." _ tVersion.Patch, 1: tVersion.ToString()) set tModRef.Repository = artifactMetadata.ImageSource set tModRef.Description = artifactMetadata.ImageDescription + set tModRef.IPMPackaging = modPackaging set tModRef.Deployed = artifactMetadata.IPMDeployed - if (artifactMetadata.IPMDeployed '= "") { + if (modPackaging = "database") { + // For database packages, store PlatformVersion so GetModule can build the correct tag. + set tModRef.PlatformVersion = platformVersion + } elseif (artifactMetadata.IPMDeployed '= "") { set pvPtr = 0 while $listnext(platformVersions, pvPtr, pv) { $$$ThrowOnError(tModRef.PlatformVersions.Insert(pv)) diff --git a/src/cls/IPM/Repo/Oras/PublishService.cls b/src/cls/IPM/Repo/Oras/PublishService.cls index 43bbe3b7a..e459b4122 100644 --- a/src/cls/IPM/Repo/Oras/PublishService.cls +++ b/src/cls/IPM/Repo/Oras/PublishService.cls @@ -39,10 +39,13 @@ Method PublishModule(pModule As %IPM.Repo.Remote.ModuleInfo) As %Status set metadata.IPMDeployed = pModule.Deployed set metadata.IPMPlatformVersion = pModule.PlatformVersion set metadata.IPMModuleV1XML = moduleXML - do metadata.%JSONExportToString(.metaDataString) - if (pModule.Deployed) { + if (pModule.IPMPackaging = "database") { + set metadata.IPMPackaging = "database" + set tag = tag _ "_database" _ $$$OrasTagPlatformSeparator _ pModule.PlatformVersion + } elseif (pModule.Deployed) { set tag = tag _ $$$OrasTagPlatformSeparator _ pModule.PlatformVersion } + $$$ThrowOnError(metadata.%JSONExportToString(.metaDataString)) #; Push file $$$ThrowOnError(..Push(..Location, repo, tag, ..Namespace, tempDirectory, metaDataString, ..Username, ..Password, ..Token, ..TokenAuthMethod)) diff --git a/src/cls/IPM/Repo/Remote/ModuleInfo.cls b/src/cls/IPM/Repo/Remote/ModuleInfo.cls index 220405643..7582bfacd 100644 --- a/src/cls/IPM/Repo/Remote/ModuleInfo.cls +++ b/src/cls/IPM/Repo/Remote/ModuleInfo.cls @@ -9,6 +9,9 @@ Property Payload As %Stream.GlobalBinary(%JSONFIELDNAME = "package"); Property Manifest As %Stream.GlobalCharacterSearchable(%JSONFIELDNAME = "manifest", SIMILARITYINDEX = "ManifestSearchIndex"); +/// Registry packaging type: "source" or "database". Used by PublishService to tag and annotate the manifest. +Property IPMPackaging As %String(VALUELIST = ",source,database") [ InitialExpression = "source" ]; + Property Installer As %Stream.GlobalCharacterSearchable(%JSONFIELDNAME = "installer"); /// NOTE: Must be redeclared in subclasses (?) diff --git a/src/cls/IPM/Repo/SearchCriteria.cls b/src/cls/IPM/Repo/SearchCriteria.cls index 487b95504..4fb7e2ae3 100644 --- a/src/cls/IPM/Repo/SearchCriteria.cls +++ b/src/cls/IPM/Repo/SearchCriteria.cls @@ -25,6 +25,9 @@ Property Keywords As list Of %String; /// empty: resolving dependencies Property AllVersions As %Boolean; +/// Filter by packaging type: "source" or "database". Empty means no filter. +Property Packaging As %String(VALUELIST = ",source,database"); + Method KeywordsSet(%value) As %Status { if $isobject(%value) { diff --git a/src/cls/IPM/ResourceProcessor/Default/Document.cls b/src/cls/IPM/ResourceProcessor/Default/Document.cls index 16e5b9a3c..c42fd89fd 100644 --- a/src/cls/IPM/ResourceProcessor/Default/Document.cls +++ b/src/cls/IPM/ResourceProcessor/Default/Document.cls @@ -103,10 +103,6 @@ Method OnPhase( // then it should have a package mapping of this specific resource to its namespace database $$$ThrowOnError(..OnConfigureMappings(.pParams)) } - if ..ResourceReference.Generated { - set pResourceHandled = 1 - quit - } if '..ResourceReference.Generated { set tSubDirectory = $select(..ResourceReference.Preload:"preload/",1:"") diff --git a/src/cls/IPM/Storage/Module.cls b/src/cls/IPM/Storage/Module.cls index 9f0ad18ac..49c46fa0c 100644 --- a/src/cls/IPM/Storage/Module.cls +++ b/src/cls/IPM/Storage/Module.cls @@ -554,6 +554,9 @@ ClassMethod ExecutePhases( set tOnePhase = tNormalizedPhaseName } set tStart = $zhorolog + // "*" is a module-object refresh checkpoint, not a real phase. + // Reopens the module and lifecycle from storage to pick up changes + // made by earlier phases (e.g., module.xml edits during Reload). if tOnePhase="*" { kill tModule,tLifecycle set tModule = ..NameOpen(pModuleName,,.tSC) @@ -1165,6 +1168,9 @@ Method ProcessSingleDependencyIterative( set searchCriteria.Name = pDep.Name set searchExprStr = searchExpr.ToResolvedString() set searchCriteria.VersionExpression = searchExprStr + // Dependencies are always resolved as source packages regardless of top-level -swap-db. + // A database-packaged module already includes its dependencies in the tgz. + set searchCriteria.Packaging = "source" if searchExprStr = "" { set searchExprStr = "*" } @@ -1521,11 +1527,24 @@ Method %OnValidateObject() As %Status [ Private, ServerOnly = 1 ] if (..Packaging = "") { set ..Packaging = tPackaging } elseif (..Packaging '= tPackaging) { - set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Module's packaging is set to '%1', but lifecycle class '%2' has packaging '%3'.",..Packaging,..LifecycleClass,tPackaging)) - quit + // The lifecycle class is still the default but Packaging has been changed. + // Derive the correct class from the Packaging value via the canonical mapping. + set tSC = ##class(%IPM.Lifecycle.Base).GetBaseClassForPackaging(..Packaging, .tDerived) + quit:$$$ISERR(tSC) + if (tDerived '= "") && $$$comClassDefined(tDerived) { + set ..LifecycleClass = tDerived + } else { + // No lifecycle class claims this packaging value, so there is nothing to switch to. + set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Module's packaging is set to '%1', which no lifecycle class implements. Lifecycle class '%2' has packaging '%3'.",..Packaging,..LifecycleClass,tPackaging)) + quit + } } } elseif (..LifecycleClass = "") && (..Packaging '= "") { - set ..LifecycleClass = $case(..Packaging,"application": "Application", : "Module") + // Derive lifecycle from packaging; fall back to the generic Module lifecycle + // for "module" and any packaging with no dedicated lifecycle class. + set tSC = ##class(%IPM.Lifecycle.Base).GetBaseClassForPackaging(..Packaging, .tDerived) + quit:$$$ISERR(tSC) + set ..LifecycleClass = $select(tDerived '= "": tDerived, 1: $$$DefaultLifecyclePackageDot _ "Module") } elseif (..Packaging = "") { // Default to "module" set ..Packaging = "module" diff --git a/src/cls/IPM/Storage/ModuleInfo.cls b/src/cls/IPM/Storage/ModuleInfo.cls index a6d62e1c4..6f060e22c 100644 --- a/src/cls/IPM/Storage/ModuleInfo.cls +++ b/src/cls/IPM/Storage/ModuleInfo.cls @@ -6,6 +6,9 @@ Parameter DEFAULTGLOBAL = "^IPM.Storage.ModuleInfo"; Property PlatformVersions As list Of %String(%JSONFIELDNAME = "platform_versions"); +/// Registry packaging type: "source" or "database". +Property IPMPackaging As %String(%JSONFIELDNAME = "ipm_packaging", VALUELIST = ",source,database") [ InitialExpression = "source" ]; + Storage Default { @@ -42,6 +45,9 @@ Storage Default DisplayName + +IPMPackaging + ModuleInfoState ^IPM.Storage.ModuleInfoS diff --git a/src/cls/IPM/Storage/QualifiedModuleInfo.cls b/src/cls/IPM/Storage/QualifiedModuleInfo.cls index 71ad0fa9b..c3cb1fddc 100644 --- a/src/cls/IPM/Storage/QualifiedModuleInfo.cls +++ b/src/cls/IPM/Storage/QualifiedModuleInfo.cls @@ -22,6 +22,8 @@ Method %OnNew( set ..PlatformVersions = pResolvedReference.PlatformVersions set ..VersionString = pResolvedReference.VersionString set ..AllVersions = pResolvedReference.AllVersions + set ..IPMPackaging = pResolvedReference.IPMPackaging + set ..PlatformVersion = pResolvedReference.PlatformVersion } quit $$$OK } @@ -65,6 +67,9 @@ Storage Default DisplayName + +IPMPackaging + %Storage.Serial } diff --git a/src/cls/IPM/Storage/ResourceReference.cls b/src/cls/IPM/Storage/ResourceReference.cls index 6f1451ed9..4cd6075c0 100644 --- a/src/cls/IPM/Storage/ResourceReference.cls +++ b/src/cls/IPM/Storage/ResourceReference.cls @@ -271,6 +271,13 @@ Method IsInScope( quit tInScope } +/// Returns true if the resource is test-scoped (Scope="test" or Scope="verify"). +Method IsTestScoped() As %Boolean +{ + set tScopeLower = $zconvert(..Scope, "L") + quit (tScopeLower = "test") || (tScopeLower = "verify") +} + /// Returns an array of resources composing this resource, with the names as subscripts of pResourceArray. /// If pCheckModuleOwnership is 0 (the default is 1), for .PKG resources, all classes in the package will /// be included (even if they are actually part of another module). diff --git a/src/cls/IPM/Test/Utils.cls b/src/cls/IPM/Test/Utils.cls index e332634ad..f9f326eea 100644 --- a/src/cls/IPM/Test/Utils.cls +++ b/src/cls/IPM/Test/Utils.cls @@ -3,7 +3,10 @@ Include %sySecurity Class %IPM.Test.Utils { -ClassMethod CreateNamespace(pNSName As %String) As %Status +/// Only ensemble-enable the namespace when required. +ClassMethod CreateNamespace( + pNSName As %String, + pEnableEnsemble As %Boolean = 0) As %Status { new $namespace set tSC = $$$OK @@ -72,9 +75,32 @@ ClassMethod CreateNamespace(pNSName As %String) As %Status } // Ensemble-enable namespace - set tSC = ##class(%EnsembleMgr).EnableNamespace(pNSName) - if $$$ISERR(tSC) { - quit + if pEnableEnsemble { + // pFromInstall=1 (4th arg) suppresses HS-specific global mappings that would otherwise + // point ^IRIS.Msg and its subscripts at HSLIB (read-only) on IRIS for Health. + set tSC = ##class(%EnsembleMgr).EnableNamespace(pNSName,,,1) + if $$$ISERR(tSC) { + quit + } + // Map ^IRIS.Msg to the namespace's own database so LOC compilation can write message domains. + set tMsgProps("Database") = pNSName + for tMsgGlobal = "IRIS.Msg", "IRIS.MsgNames" { + if ##class(Config.MapGlobals).Exists(pNSName, tMsgGlobal) { + set tSC = ##class(Config.MapGlobals).Modify(pNSName, tMsgGlobal, .tMsgProps) + } else { + set tSC = ##class(Config.MapGlobals).Create(pNSName, tMsgGlobal, .tMsgProps) + } + if $$$ISERR(tSC) { quit } + } + if $$$ISERR(tSC) { + quit + } + set tSC = ##class(Config.CPF).Write() + if $$$ISERR(tSC) { quit } + set tSC = ##class(Config.Map).MoveToActive() + if $$$ISERR(tSC) { quit } + set tSC = ##class(Config.Namespaces).Load(pNSName) + if $$$ISERR(tSC) { quit } } // Create default web application @@ -128,6 +154,10 @@ ClassMethod DeleteNamespace(pNSName As %String) As %Status set tEnsTempDB = pNSName_##class(%Library.EnsembleMgr).#TEMPSUFFIX for tOtherDB = tSecondaryDB,tEnsTempDB { set tSC = $$$OK + // Absent unless the namespace was ensemble-enabled + if '##class(Config.Databases).Exists(tOtherDB) { + continue + } set tDB = ##class(Config.Databases).Open(tOtherDB,,.tSC) if $isobject(tDB) { // Delete database diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls index 87e3208a2..8d66f1917 100644 --- a/src/cls/IPM/Utils/Module.cls +++ b/src/cls/IPM/Utils/Module.cls @@ -28,25 +28,25 @@ ClassMethod LoadQualifiedReference( { set tSC = $$$OK try { - #dim tReference As %IPM.Storage.QualifiedModuleInfo - set tReference = pReference.%ConstructClone() - set tSC = ..LoadModuleReference(tReference.ServerName, tReference.Name, tReference.VersionString, tReference.Deployed, tReference.PlatformVersion, .pParams, .pDependencyGraph, pLog) + set tSC = ..LoadModuleReference(pReference, .pParams, .pDependencyGraph, pLog) } catch e { set tSC = e.AsStatus() } quit tSC } +/// Loads a module resolved from a repository. pReference is cloned rather than used directly, since this method adds the current +/// platform version to PlatformVersions for deployed modules. ClassMethod LoadModuleReference( - pServerName As %String, - pModuleName As %String, - pVersion As %String, - pDeployed As %Boolean, - pPlatformVersion As %String, + pReference As %IPM.Storage.QualifiedModuleInfo, ByRef pParams, ByRef pDependencyGraph, pLog As %IPM.General.AbstractHistory = "") As %Status { + set serverName = pReference.ServerName + set moduleName = pReference.Name + set version = pReference.VersionString + // if install of module is already being logged, don't duplicate set prelogged = $case(pLog, "": 0, :1) @@ -62,12 +62,12 @@ ClassMethod LoadModuleReference( } if pLog = "" { if $get(pParams("cmd")) = "install" { - set log = ##class(%IPM.General.HistoryTemp).InstallInit(pModuleName) + set log = ##class(%IPM.General.HistoryTemp).InstallInit(moduleName) } else { - set log = ##class(%IPM.General.HistoryTemp).LoadInit(pModuleName) + set log = ##class(%IPM.General.HistoryTemp).LoadInit(moduleName) } - $$$ThrowOnError(log.SetSource(pServerName)) - $$$ThrowOnError(log.SetVersion(pVersion)) + $$$ThrowOnError(log.SetSource(serverName)) + $$$ThrowOnError(log.SetVersion(version)) } else { set log = pLog } @@ -84,28 +84,24 @@ ClassMethod LoadModuleReference( set manager = ##class(%IPM.Repo.Manager).%Get(.tSC) $$$ThrowOnError(tSC) - set tClient = manager.CheckServiceCache(pServerName,.tAvailable) + set tClient = manager.CheckServiceCache(serverName,.tAvailable) if 'tAvailable { - set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Repository '%1' is unavailable.",pServerName)) + set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Repository '%1' is unavailable.",serverName)) quit } - set tModRef = ##class(%IPM.Storage.ModuleInfo).%New() - set tModRef.Name = pModuleName - set tModRef.VersionString = pVersion - set tModRef.Deployed = pDeployed - set tModRef.PlatformVersion = pPlatformVersion - if pDeployed { - $$$ThrowOnError(tModRef.PlatformVersions.Insert(pPlatformVersion)) + set tModRef = pReference.%ConstructClone() + if tModRef.Deployed && 'tModRef.PlatformVersions.Find(tModRef.PlatformVersion) { + $$$ThrowOnError(tModRef.PlatformVersions.Insert(tModRef.PlatformVersion)) } // Make sure we're not downgrading. if '$get(pParams("PermitDowngrade")) { - if ##class(%IPM.Storage.Module).NameExists(pModuleName) { - set tInstModule = ##class(%IPM.Storage.Module).NameOpen(pModuleName,,.tSC) + if ##class(%IPM.Storage.Module).NameExists(moduleName) { + set tInstModule = ##class(%IPM.Storage.Module).NameOpen(moduleName,,.tSC) if $$$ISERR(tSC) { quit } - if $get(pParams("Install")),pModuleName'=$$$IPMModuleName,tInstModule.DeveloperMode,'$get(pParams("DeveloperMode"),0) { + if $get(pParams("Install")),moduleName'=$$$IPMModuleName,tInstModule.DeveloperMode,'$get(pParams("DeveloperMode"),0) { set tSC = $$$ERROR($$$GeneralError, $$$FormatText("Cannot install '%1' over previously installed in developer mode", tInstModule.Name)) quit } @@ -114,13 +110,13 @@ ClassMethod LoadModuleReference( // Ensure requested versions match those required by other modules in the namespace, excluding versions currently being installed // (the requirements of such modules are already known to be satisfied) - set tSC = ..GetRequiredVersionExpression(pModuleName,,.pDependencyGraph,.tExpression,.tSourceList) + set tSC = ..GetRequiredVersionExpression(moduleName,,.pDependencyGraph,.tExpression,.tSourceList) if $$$ISERR(tSC) { quit } if 'tExpression.IsSatisfiedBy(tModRef.Version) { set tSourceString = $listtostring(tSourceList,"; ") - set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Requested version (%1 %2) does not satisfy the requirements of other modules installed in the current namespace (%3).",tModRef.Name,pVersion,tSourceString)) + set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Requested version (%1 %2) does not satisfy the requirements of other modules installed in the current namespace (%3).",tModRef.Name,version,tSourceString)) quit } @@ -135,14 +131,14 @@ ClassMethod LoadModuleReference( } set dotModules = ##class(%File).NormalizeDirectory(".modules", tDirectory) set tmpRepoMgr = ##class(%IPM.General.TempLocalRepoManager).%New(dotModules, 0) - set tSC = ..LoadModuleFromDirectory(tDirectory,.pParams,tDeveloperMode,pServerName,log) + set tSC = ..LoadModuleFromDirectory(tDirectory,.pParams,tDeveloperMode,serverName,log) set tSC = $$$ADDSC(tSC, tmpRepoMgr.CleanUp()) } else { set tAsArchive = -1 set tPayload = tClient.GetModule(tModRef, .tAsArchive) if (tVerbose) { - set serverDef = ##class(%IPM.Repo.Definition).ServerDefinitionKeyOpen(pServerName) - write !,"Module "_pModuleName_" was downloaded from " _ pServerName + set serverDef = ##class(%IPM.Repo.Definition).ServerDefinitionKeyOpen(serverName) + write !,"Module "_moduleName_" was downloaded from " _ serverName if $isobject(serverDef) { write " (",serverDef.Details,")" } @@ -169,7 +165,7 @@ ClassMethod LoadModuleReference( do tTmpStream.CopyFromAndSave(tPayload) if (tAsArchive) { - set tSC = ..LoadModuleFromArchive(tModRef.Name,tModRef.VersionString,tTmpStream,.pParams, pServerName,log) + set tSC = ..LoadModuleFromArchive(tModRef.Name,tModRef.VersionString,tTmpStream,.pParams, serverName,log) } else { // Old format (TODO: officially deprecate): try loading a .xml file @@ -190,7 +186,7 @@ ClassMethod LoadModuleReference( set errorMsg = $system.Status.GetErrorText(tSC) } if ($system.CLS.IsMthd(tClient, "CollectAnalytics")) { - do tClient.CollectAnalytics("install", pModuleName, pVersion,success,errorMsg) + do tClient.CollectAnalytics("install", moduleName, version,success,errorMsg) } } if $$$ISERR(tSC) { @@ -1175,6 +1171,17 @@ ClassMethod LoadNewModule( if moduleCurrent '= "" { set fromVersion = moduleCurrent.Version set fromVersionString = moduleCurrent.VersionString + if foundNewModuleObj && (newModuleObj.Packaging = "database") { + // Capture the currently installed version's flattened dependency set before the + // load below overwrites this module's stored manifest. The database install needs + // it to tell a dependency this package used to carry (its code came from the old + // IRIS.DAT and is about to be replaced) from an unrelated module the user + // installed themselves (whose code the swap would destroy). Flattened, so a + // dependency that merely moved to a different parent is not seen as dropped. + // A dependency with no installed record is omitted, which is harmless: with no + // record there is nothing to orphan and nothing to clean up. + set params("PreviousDependencies") = ##class(%IPM.Lifecycle.Database).GetDependencyNames(moduleCurrent) + } } if (fromVersion '= "") && (toVersion '= "") { if fromVersion.Follows(toVersion) { @@ -1283,7 +1290,12 @@ ClassMethod LoadNewModule( if $get(params("CreateLockFile"), 0) && '$data(params("LockFileModule")){ set params("LockFileModule") = tModule.Name } - do ..LoadDependencies(tModule,, .params) + // For database packages, all dependencies are compiled into IRIS.DAT — skip LoadDependencies. + // Fetching deps from a registry would fail (code isn't installed yet before the DB swap). + // %Reload registers their metadata from the deps/ directory once the swap completes. + if tModule.Packaging '= "database" { + do ..LoadDependencies(tModule,, .params) + } set tSC = $system.OBJ.Load(pDirectory_"module.xml",$select(tVerbose:"d",1:"-d"),,.tLoadedList) $$$ThrowOnError(tSC) @@ -1363,7 +1375,7 @@ ClassMethod LoadDependencies( // Ignore modules already installed that do not need to be installed again continue } - set sc = ..LoadModuleReference(moduleReference.ServerName, moduleReference.Name, moduleReference.VersionString, moduleReference.Deployed, moduleReference.PlatformVersion, .pParams, .dependencyGraph) + set sc = ..LoadModuleReference(moduleReference, .pParams, .dependencyGraph) $$$ThrowOnError(sc) } diff --git a/tests/integration_tests/Test/PM/Integration/Base.cls b/tests/integration_tests/Test/PM/Integration/Base.cls index 3b1663e73..ca8490477 100644 --- a/tests/integration_tests/Test/PM/Integration/Base.cls +++ b/tests/integration_tests/Test/PM/Integration/Base.cls @@ -7,6 +7,10 @@ Parameter CLIENTNS As STRING = "UTCLIENT"; Parameter NEEDSREGISTRY As BOOLEAN = 1; +/// Set to 1 in subclasses that load interoperability resources (DTL, BPL, productions) into +/// the client namespace. Off by default. +Parameter NEEDSINTEROP As BOOLEAN = 0; + Property UserCreated As %Boolean [ InitialExpression = 0 ]; Property ClientNSCreated As %Boolean [ InitialExpression = 0 ]; @@ -21,7 +25,7 @@ Method Setup() As %Status do $$$AssertStatusOK(##class(%IPM.Repo.Filesystem.Definition).%DeleteExtent()) do $$$AssertStatusOK(##class(%IPM.Repo.Remote.Definition).%DeleteExtent()) - if '$$$AssertStatusOK(##class(%IPM.Test.Utils).CreateNamespace(..#CLIENTNS)) { + if '$$$AssertStatusOK(##class(%IPM.Test.Utils).CreateNamespace(..#CLIENTNS, ..#NEEDSINTEROP)) { quit } set ..ClientNSCreated = 1 diff --git a/tests/integration_tests/Test/PM/Integration/DatabasePackaging.cls b/tests/integration_tests/Test/PM/Integration/DatabasePackaging.cls new file mode 100644 index 000000000..2013b4cec --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/DatabasePackaging.cls @@ -0,0 +1,1732 @@ +/// Test class for database packaging functionality +Class Test.PM.Integration.DatabasePackaging Extends Test.PM.Integration.Base +{ + +/// Namespaces created during the current test — cleaned up in OnAfterOneTest. +Property CreatedNamespaces As %List; + +/// Namespace active when the test class was invoked — restored after each test. +Property OriginalNamespace As %String; + +/// Directory where .tgz packages are written during tests — wiped and recreated in OnBeforeOneTest. +Property PackageOutputDir As %String; + +/// Directory holding database packages shared across tests, unlike PackageOutputDir which is +/// wiped per test. Created in OnBeforeAllTests, removed in OnAfterAllTests. +Property SharedPackageDir As %String; + +/// Globals databases created per-test — cleaned up in OnAfterOneTest. +/// Separate from the routines DB so dismounting during package-database doesn't lose IPM metadata. +Property CreatedGlobalsDatabases As %List; + +/// Packaging namespaces are shared across tests to avoid the ~10s overhead of creating and +/// tearing down a namespace + globals DB for every test. Each shared NS is lazily initialized +/// on first use and cleaned up in OnAfterAllTests. Tests still create their own install +/// namespaces via CreateTestNamespace — those are per-test and cleaned up in OnAfterOneTest. +/// +/// Sharing is safe because package-database does not modify namespace state — it creates a +/// temp DB elsewhere, compiles into it, and discards it. The loaded classes, globals, and +/// module registry in the shared NS are untouched. Any on-disk side effects (e.g. wheels +/// downloaded by ExportPythonDependencies) are the responsibility of the individual tests. +/// +/// Shared NS for the majority of tests — generally along compiled-resource lines. +/// Lazily created by GetOrCreateSharedNS1; cleaned up in OnAfterAllTests. +Property SharedNS1 As %String; + +/// Shared NS for Python/resource-heavy tests — generally along non-compiled-resource lines. +/// Lazily created by GetOrCreateSharedNS2; cleaned up in OnAfterAllTests. +/// Note: Each module loaded here must use a distinct PythonWheel filename — IPM disallows +/// two modules in the same namespace from claiming the same wheel. If adding a module with a +/// PythonWheel or requirements.txt, ensure it uses a wheel not already claimed by another +/// module in this namespace (e.g. lune, colorama, pycparser, packaging, six are already taken). +Property SharedNS2 As %String; + +/// Globals databases for shared namespaces — tracked separately since they outlive individual tests. +/// Cleaned up in OnAfterAllTests. +Property SharedGlobalsDatabases As %List; + +/// Cached .tgz paths for modules packaged identically by more than one test, keyed by module name. +/// Reuse is safe: `load ` extracts to a temp directory and copies from there, so the archive +/// itself is never modified by an install (including the swap and tamper flows). +Property CachedPackages As array Of %String; + +/// Defensive cleanup of leftover shared namespaces from interrupted previous runs. +Method OnBeforeAllTests() As %Status +{ + set ..OriginalNamespace = $namespace + set ..SharedNS1 = "" + set ..SharedNS2 = "" + set ..SharedGlobalsDatabases = "" + + set ..SharedPackageDir = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "pkg-test-shared") + if ##class(%Library.File).DirectoryExists(..SharedPackageDir) { + do ##class(%IPM.Utils.File).RemoveDirectoryTree(..SharedPackageDir) + } + do ##class(%File).CreateDirectory(..SharedPackageDir) + + for i=1:1:2 { + set ns = "TESTDBPKGNS" _ i + set globalsDB = ns _ "G" + do ##class(%IPM.Test.Utils).DeleteNamespace(ns) + set dbPath = ##class(%IPM.Lifecycle.Database).GetDatabaseDirectory(globalsDB) + try { + new $namespace + set $namespace = "%SYS" + do ##class(SYS.Database).DismountDatabase(dbPath) + if ##class(Config.Databases).Exists(globalsDB) { + do ##class(Config.Databases).Delete(globalsDB) + } + do ##class(SYS.Database).DeleteDatabase(dbPath) + set $namespace = ..OriginalNamespace + if ##class(%Library.File).DirectoryExists(dbPath) { + do ##class(%IPM.Utils.File).RemoveDirectoryTree(dbPath) + } + } catch ex { + // Best-effort — may not exist from a previous clean run + } + } + quit $$$OK +} + +/// Resets test state: clears namespace/DB tracking lists, records original namespace, +/// and creates a clean package output directory (wiping any leftover from previous runs). +Method OnBeforeOneTest() As %Status +{ + set ..CreatedNamespaces = "" + set ..CreatedGlobalsDatabases = "" + set ..OriginalNamespace = $namespace + set ..PackageOutputDir = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "pkg-test-output") + if ##class(%Library.File).DirectoryExists(..PackageOutputDir) { + do ##class(%IPM.Utils.File).RemoveDirectoryTree(..PackageOutputDir) + } + do ##class(%File).CreateDirectory(..PackageOutputDir) + return $$$OK +} + +/// Tears down all test namespaces and their dedicated globals databases created during the test. +/// Always restores $namespace to the original namespace first. +Method OnAfterOneTest() As %Status +{ + set sc = $$$OK + set $namespace = ..OriginalNamespace + + // Clean up all created namespaces (and their main routines databases) + for i=1:1:$listlength(..CreatedNamespaces) { + set ns = $listget(..CreatedNamespaces, i) + if (ns '= "") { + set cleanupSC = ##class(%IPM.Test.Utils).DeleteNamespace(ns) + set sc = $$$ADDSC(sc, cleanupSC) + } + } + + // Clean up the separate globals databases + for i=1:1:$listlength(..CreatedGlobalsDatabases) { + set dbName = $listget(..CreatedGlobalsDatabases, i) + if dbName '= "" { + set cleanupSC = $$$OK + try { + new $namespace + set $namespace = "%SYS" + set dbPath = ##class(%Library.File).NormalizeDirectory( + ##class(%Library.File).ManagerDirectory() _ dbName) + // Best-effort dismount — may already be dismounted + do ##class(SYS.Database).DismountDatabase(dbPath) + $$$ThrowOnError(##class(Config.Databases).Delete(dbName)) + $$$ThrowOnError(##class(SYS.Database).DeleteDatabase(dbPath)) + set $namespace = ..OriginalNamespace + if ##class(%Library.File).DirectoryExists(dbPath) { + do ##class(%IPM.Utils.File).RemoveDirectoryTree(dbPath) + } + } catch ex { + set cleanupSC = ex.AsStatus() + } + set sc = $$$ADDSC(sc, cleanupSC) + } + } + + return sc +} + +/// Tears down shared namespaces (TESTDBPKGNS1, TESTDBPKGNS2) and their globals databases. +/// Per-test namespaces and DBs are handled by OnAfterOneTest. +Method OnAfterAllTests() As %Status +{ + set sc = $$$OK + set $namespace = ..OriginalNamespace + + // Clean up shared packaging namespaces (routines DBs) + if ..SharedNS1 '= "" { + set sc = $$$ADDSC(sc, ##class(%IPM.Test.Utils).DeleteNamespace(..SharedNS1)) + } + if ..SharedNS2 '= "" { + set sc = $$$ADDSC(sc, ##class(%IPM.Test.Utils).DeleteNamespace(..SharedNS2)) + } + + if (..SharedPackageDir '= "") && ##class(%Library.File).DirectoryExists(..SharedPackageDir) { + do ##class(%IPM.Utils.File).RemoveDirectoryTree(..SharedPackageDir) + } + + // Clean up shared globals databases + for i=1:1:$listlength(..SharedGlobalsDatabases) { + set dbName = $listget(..SharedGlobalsDatabases, i) + if dbName '= "" { + set dbPath = ##class(%IPM.Lifecycle.Database).GetDatabaseDirectory(dbName) + try { + new $namespace + set $namespace = "%SYS" + do ##class(SYS.Database).DismountDatabase(dbPath) + if ##class(Config.Databases).Exists(dbName) { + $$$ThrowOnError(##class(Config.Databases).Delete(dbName)) + } + $$$ThrowOnError(##class(SYS.Database).DeleteDatabase(dbPath)) + set $namespace = ..OriginalNamespace + if ##class(%Library.File).DirectoryExists(dbPath) { + do ##class(%IPM.Utils.File).RemoveDirectoryTree(dbPath) + } + } catch ex { + set sc = $$$ADDSC(sc, ex.AsStatus()) + } + } + } + + return sc +} + +/// Creates a test namespace with a separate globals database. +/// The routines database (pNamespace) can be safely dismounted during +/// package-database or database swap without losing IPM metadata globals. +Method CreateTestNamespace(pNamespace As %String) As %Status +{ + // Create the standard namespace (initially Globals=pNamespace, Routines=pNamespace) + set sc = ##class(%IPM.Test.Utils).CreateNamespace(pNamespace) + if $$$ISERR(sc) { + return sc + } + set ..CreatedNamespaces = ..CreatedNamespaces _ $listbuild(pNamespace) + + // Create a dedicated globals database so dismounting the routines DB + // during packaging or swap doesn't lose IPM metadata (^IPM.Storage.*) + set globalsDBName = pNamespace _ "G" + set sc = $$$OK + try { + new $namespace + set $namespace = "%SYS" + + set globalsPath = ##class(%Library.File).NormalizeDirectory( + ##class(%Library.File).ManagerDirectory() _ globalsDBName) + + // Clean up any leftover state from a previous test run + // Check both directory existence and DB registration independently + if ##class(%Library.File).DirectoryExists(globalsPath) { + do ##class(SYS.Database).DismountDatabase(globalsPath) + do ##class(SYS.Database).DeleteDatabase(globalsPath) + if ##class(%Library.File).DirectoryExists(globalsPath) { + set $namespace = ..OriginalNamespace + do ##class(%IPM.Utils.File).RemoveDirectoryTree(globalsPath) + set $namespace = "%SYS" + } + } + if ##class(Config.Databases).Exists(globalsDBName) { + do ##class(Config.Databases).Delete(globalsDBName) + } + + if '##class(%Library.File).CreateDirectory(globalsPath, .result) { + set tMsg = "Failed to create globals DB directory: " _ result + $$$ThrowStatus($$$ERROR($$$GeneralError, tMsg)) + } + + $$$ThrowOnError(##class(SYS.Database).CreateDatabase(globalsPath)) + + set dbProps("Directory") = globalsPath + set dbProps("MountRequired") = 1 + $$$ThrowOnError(##class(Config.Databases).Create(globalsDBName, .dbProps)) + + set mountSC = ##class(SYS.Database).MountDatabase(globalsPath) + if $$$ISERR(mountSC) && '$system.Status.Equals(mountSC, $$$ERRORCODE($$$AlreadyMounted)) { + $$$ThrowOnError(mountSC) + } + + $$$ThrowOnError(##class(Config.Namespaces).Get(pNamespace, .nsProps)) + set nsProps("Globals") = globalsDBName + $$$ThrowOnError(##class(Config.Namespaces).Modify(pNamespace, .nsProps)) + + set ..CreatedGlobalsDatabases = ..CreatedGlobalsDatabases _ $listbuild(globalsDBName) + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +/// Moves the namespace CreateTestNamespace just registered from per-test tracking to shared +/// tracking, so OnAfterOneTest does not tear down a namespace later tests still need. +/// Call immediately after CreateTestNamespace — it operates on the last entry of each list. +Method PromoteLastCreatedToShared() +{ + set lastIdx = $listlength(..CreatedNamespaces) + set ..CreatedNamespaces = $list(..CreatedNamespaces, 1, lastIdx - 1) + set lastIdx = $listlength(..CreatedGlobalsDatabases) + set lastDB = $list(..CreatedGlobalsDatabases, lastIdx) + set ..CreatedGlobalsDatabases = $list(..CreatedGlobalsDatabases, 1, lastIdx - 1) + set ..SharedGlobalsDatabases = ..SharedGlobalsDatabases _ $listbuild(lastDB) +} + +/// Returns the shared packaging namespace for core modules (generally compiled-resource tests). +/// Creates TESTDBPKGNS1 on first call and loads all relevant modules; reuses on subsequent calls. +/// GetModuleDir calls are made before any namespace switch since ^UnitTestRoot is only in origNS. +Method GetOrCreateSharedNS1() As %String +{ + if ..SharedNS1 '= "" { + quit ..SharedNS1 + } + + set ns = "TESTDBPKGNS1" + + // Resolve all module dirs before switching namespace (^UnitTestRoot only in original NS) + set depModuleDir = ..GetModuleDir("db-packaging", "dependency-module") + set simpleDir = ..GetModuleDir("db-packaging", "simple-module") + set withDepsDir = ..GetModuleDir("db-packaging", "module-with-deps") + set testsDir = ..GetModuleDir("db-packaging", "module-with-tests") + set invokesDir = ..GetModuleDir("db-packaging", "module-with-invokes") + + $$$ThrowOnError(..CreateTestNamespace(ns)) + do ..PromoteLastCreatedToShared() + + new $namespace + set $namespace = ns + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ depModuleDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ simpleDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ withDepsDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ testsDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ invokesDir)) + // Configure the local zot ORAS registry once for ORAS-related tests + $$$ThrowOnError(##class(%IPM.Main).Shell("repo -o -name zot -url http://oras:5000")) + + set ..SharedNS1 = ns + quit ns +} + +/// Returns the shared packaging namespace for Python/resource-heavy modules (generally non-compiled-resource tests). +/// Creates TESTDBPKGNS2 on first call and loads all relevant modules; reuses on subsequent calls. +Method GetOrCreateSharedNS2() As %String +{ + if ..SharedNS2 '= "" { + quit ..SharedNS2 + } + + set ns = "TESTDBPKGNS2" + + // Resolve all module dirs before switching namespace (^UnitTestRoot only in original NS) + set allResDir = ..GetModuleDir("db-packaging", "module-with-all-resources") + set reqsDir = ..GetModuleDir("db-packaging", "module-with-requirements") + set mixPyDir = ..GetModuleDir("db-packaging", "module-with-mixed-python") + set nonCmpDir = ..GetModuleDir("db-packaging", "module-with-non-compiled-resources") + set depResDir = ..GetModuleDir("db-packaging", "dep-with-resources") + set mainResDir = ..GetModuleDir("db-packaging", "main-with-resource-deps") + + $$$ThrowOnError(..CreateTestNamespace(ns)) + do ..PromoteLastCreatedToShared() + + new $namespace + set $namespace = ns + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ allResDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ reqsDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ mixPyDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ nonCmpDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ depResDir)) + $$$ThrowOnError(##class(%IPM.Main).Shell("load -v " _ mainResDir)) + + set ..SharedNS2 = ns + quit ns +} + +/// Returns the path to a database package for pModule, building it in the shared packaging +/// namespace on first request and reusing the .tgz afterwards. Only for modules that multiple +/// tests need packaged with default options — tests that mutate the module or pass extra +/// packaging flags must call package-database themselves. +Method GetOrCreateDatabasePackage( + pModule As %String, + pVersion As %String = "1.0.0") As %String +{ + if ..CachedPackages.IsDefined(pModule) { + quit ..CachedPackages.GetAt(pModule) + } + + set packagingNS = ..GetOrCreateSharedNS1() + new $namespace + set $namespace = packagingNS + $$$ThrowOnError(##class(%IPM.Main).Shell("package-database " _ pModule _ " -path " _ ..SharedPackageDir)) + + set packageFile = ..SharedPackageDir _ pModule _ "-" _ pVersion _ "-database.tgz" + if '##class(%Library.File).Exists(packageFile) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Expected package not created: " _ packageFile)) + } + do ..CachedPackages.SetAt(packageFile, pModule) + quit packageFile +} + +/// Test simple package, install and uninstall with database packaging +Method TestSimplePackageAndInstall() +{ + new $namespace + set packageFile = ..GetOrCreateDatabasePackage("simple-db-module") + do $$$AssertTrue(##class(%File).Exists(packageFile), "Database package .tgz file exists") + + // Extract and verify contents + set extractDir = ##class(%File).NormalizeDirectory(..PackageOutputDir _ "extract") + do ##class(%File).CreateDirectory(extractDir) + set sc = ##class(%IPM.General.Archive).Extract(packageFile, extractDir) + do $$$AssertStatusOK(sc, "Extracted package contents for verification") + + // Verify IRIS.DAT exists in extracted package + set irisDataFile = ##class(%File).NormalizeDirectory(extractDir) _ "IRIS.DAT" + do $$$AssertTrue(##class(%File).Exists(irisDataFile), "IRIS.DAT file exists in package") + do $$$AssertTrue(##class(%File).GetFileSize(irisDataFile) > 0, "IRIS.DAT is not zero-sized") + + // Verify module.xml exists and contains database packaging metadata + set moduleXMLFile = ##class(%File).NormalizeDirectory(extractDir) _ "module.xml" + do $$$AssertTrue(##class(%File).Exists(moduleXMLFile), "module.xml exists in package") + + // Read and verify module.xml content + set stream = ##class(%Stream.FileCharacter).%New() + set sc = stream.LinkToFile(moduleXMLFile) + do $$$AssertStatusOK(sc, "Linked to module.xml file") + + set xmlContent = stream.Read() + do $$$AssertTrue(xmlContent [ "database", "module.xml contains database") + do $$$AssertTrue(xmlContent [ "", "module.xml contains SHA-256 checksum") + + // SystemRequirements Version should be auto-populated with the current IRIS version + set currentIrisVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + do $$$AssertTrue(xmlContent [ "Version=""" _ currentIrisVersion _ """", "module.xml SystemRequirements Version matches current IRIS version") + + // Verify deps/ directory exists + set extractedDepsDir = ##class(%File).NormalizeDirectory(extractDir) _ "deps/" + do $$$AssertTrue(##class(%File).DirectoryExists(extractedDepsDir), "deps/ directory exists in package") + + // Create a new test namespace for installation + set installNS = "TESTDBINST1" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created installation test namespace") + + set $namespace = installNS + + // Install database package with -swap-db flag + set sc = ##class(%IPM.Main).Shell("load " _ packageFile _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed database package successfully") + + // Verify classes are accessible + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("SimpleModule.Main"), "SimpleModule.Main class exists") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("SimpleModule.Utils"), "SimpleModule.Utils class exists") + + // Verify classes work correctly + set result = ##class(SimpleModule.Main).GetMessage() + do $$$AssertEquals(result, "Hello from Simple DB Module", "SimpleModule.Main.GetMessage() works") + + set addResult = ##class(SimpleModule.Main).Add(5, 3) + do $$$AssertEquals(addResult, 8, "SimpleModule.Main.Add() works") + + // Verify module appears in zpm list + set cookie = "" + do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie) + set sc = ##class(%IPM.Main).Shell("list") + do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output) + do $$$AssertStatusOK(sc, "List command executed") + do $$$AssertTrue(..FindStringInMultiDimArray("simple-db-module", .output), "Module appears in zpm list") + + // Verify installation is recorded in history + do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie) + set sc = ##class(%IPM.Main).Shell("history") + do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .historyOutput) + do $$$AssertStatusOK(sc, "History command executed") + do $$$AssertTrue(..FindStringInMultiDimArray("simple-db-module", .historyOutput), "Installation recorded in history") + + // --- Uninstall --- + set sc = ##class(%IPM.Main).Shell("uninstall simple-db-module") + do $$$AssertStatusOK(sc, "Uninstalled database package") + + do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie) + set sc = ##class(%IPM.Main).Shell("list") + do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .afterUninstallOutput) + do $$$AssertNotTrue(..FindStringInMultiDimArray("simple-db-module", .afterUninstallOutput), "Module no longer in list") + + // Backup file is preserved after uninstall — the user manages cleanup + do $$$AssertTrue(..CountBackupFiles(installNS) > 0, "Backup file preserved after uninstall") + + // Uninstall deletes compiled resources from the routines DB + do $$$AssertNotTrue(##class(%Dictionary.ClassDefinition).%ExistsId("SimpleModule.Main"), "Classes removed from namespace after uninstall") +} + +/// Test packaging and installing modules with dependencies +/// Tests dependency packaging behaviour: both default flow and -use-current-db must +/// produce a self-contained IRIS.DAT that includes transitive dependency code. +/// +/// Isolation check: packaging dep-module alone must NOT include MainModule.App +/// (main-with-deps is installed in the packaging NS but never compiled into dep-module's DB). +Method TestPackageAndInstallWithDependencies() +{ + new $namespace + + // Both packages are built up front: GetOrCreateDatabasePackage may have to create the shared + // packaging namespace, which resolves module directories from ^UnitTestRoot in this namespace. + set depPkg = ..GetOrCreateDatabasePackage("dep-module") + set mainPkg = ..GetOrCreateDatabasePackage("main-with-deps") + + // --- Part 1: dep-module (no deps) — isolation check --- + // main-with-deps depends on dep-module, but must not appear in dep-module's IRIS.DAT. + set installNS1 = "TESTDBDEPINST1" + set sc = ..CreateTestNamespace(installNS1) + do $$$AssertStatusOK(sc, "Created install NS for dep-module") + set $namespace = installNS1 + + set sc = ##class(%IPM.Main).Shell("load " _ depPkg _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed dep-module database package") + + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepModule.Core"), "DepModule.Core present after dep-module install") + do $$$AssertNotTrue(##class(%Dictionary.ClassDefinition).%ExistsId("MainModule.App"), "MainModule.App absent — unpackaged parent not compiled into dep-module's IRIS.DAT") + set result = ##class(DepModule.Core).GetVersion() + do $$$AssertEquals(result, "1.0.0", "DepModule.Core.GetVersion() works") + + // --- Part 2: main-with-deps default flow — deps must be included --- + // The IRIS.DAT must be self-contained: dep code compiled in, UseDependency() callable. + set installNS2 = "TESTDBDEPINST2" + set sc = ..CreateTestNamespace(installNS2) + do $$$AssertStatusOK(sc, "Created install NS for main-with-deps") + set $namespace = installNS2 + + set sc = ##class(%IPM.Main).Shell("load " _ mainPkg _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed main-with-deps") + + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("MainModule.App"), "MainModule.App present after install") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepModule.Core"), "DepModule.Core present — transitive dep compiled into IRIS.DAT") + set depResult = ##class(MainModule.App).UseDependency() + do $$$AssertTrue(depResult [ "1.0.0", "UseDependency() works — dep code is callable") + + // dep-module metadata also registered via deps/ manifest + set cookie = "" + do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie) + set sc = ##class(%IPM.Main).Shell("list") + do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .listOutput) + do $$$AssertTrue(..FindStringInMultiDimArray("dep-module", .listOutput), "dep-module appears in list (metadata from deps/)") +} + +/// Smoke test: a module using every compiled resource type (class, include, generated class, +/// WebApplication) packages and installs correctly as a database package. +/// Asserts on the compiled outputs — class existence, runtime behavior, WebApp creation. +/// +/// Distinct from TestNonCompiledResourcesAppliedDuringInstall: this test does not attempt to +/// prove that non-compiled processor hooks fire during install. It uses module-with-all-resources +/// (FileCopy, PythonWheel, WebApp present) because real modules mix resource types; the goal +/// is to confirm nothing breaks, not to assert on each non-compiled resource individually. +Method TestPackageAndInstallAllResourceTypes() +{ + set packagingNS = ..GetOrCreateSharedNS2() + + // Switch to shared packaging namespace (all-resources-module is pre-loaded) + new $namespace + set $namespace = packagingNS + + // Package as database + set sc = ##class(%IPM.Main).Shell("package-database all-resources-module -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged module with all resources") + + set packageFile = ..PackageOutputDir _ "all-resources-module-1.0.0-database.tgz" + + // Create installation namespace + set installNS = "TESTDBRESINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created installation namespace") + + set $namespace = installNS + + // Install the package + set sc = ##class(%IPM.Main).Shell("load " _ packageFile _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed module with all resources") + + // Verify classes exist + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("AllResources.Main"), "AllResources.Main class exists") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("AllResources.Generated"), "Generated resource class exists") + + // Verify class can use include file + set result = ##class(AllResources.Main).Process() + do $$$AssertTrue(result [ "TestValue", "Include file constant is accessible") + + // Verify generated resource works + set genResult = ##class(AllResources.Generated).GetGeneratedValue() + do $$$AssertEquals(genResult, "Generated resource value", "Generated resource class works") + + // Verify WebApplication was created + new $namespace + set $namespace = "%SYS" + set webAppExists = ##class(Security.Applications).Exists("/allresources") + set $namespace = installNS + do $$$AssertTrue(webAppExists, "WebApplication /allresources created") + +} + +/// Test packaging with and without test resources +Method TestPackageAndInstallWithTestResources() +{ + set packagingNS = ..GetOrCreateSharedNS1() + + // Switch to shared packaging namespace (module-with-tests is pre-loaded) + new $namespace + set $namespace = packagingNS + + // Both packages have the same filename, so they go to separate directories — otherwise the + // second package overwrites the first and both installs below exercise the same .tgz. + set withTestsDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "with-tests") + set noTestsDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "no-tests") + + // Package WITH test resources + set sc = ##class(%IPM.Main).Shell("package-database module-with-tests -dev -path " _ withTestsDir) + do $$$AssertStatusOK(sc, "Packaged with test resources") + + // Install and verify test resources are present + set packageFileWithTests = withTestsDir _ "module-with-tests-1.0.0-database.tgz" + + set installNS1 = "TESTDBTESTINS1" + set sc = ..CreateTestNamespace(installNS1) + do $$$AssertStatusOK(sc, "Created installation namespace 1") + + set $namespace = installNS1 + set sc = ##class(%IPM.Main).Shell("load " _ packageFileWithTests _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed with test resources") + + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("TestModule.Test"), "Test class (Type B UnitTest) exists when included") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("TestModule.TestHelper"), "TestHelper class (Type A Scope=test) exists when included") + + // Package WITHOUT test resources (default) + set $namespace = packagingNS + set sc = ##class(%IPM.Main).Shell("package-database module-with-tests -path " _ noTestsDir) + do $$$AssertStatusOK(sc, "Packaged without test resources") + + set packageFileNoTests = noTestsDir _ "module-with-tests-1.0.0-database.tgz" + + set installNS2 = "TESTDBTESTINS2" + set sc = ..CreateTestNamespace(installNS2) + do $$$AssertStatusOK(sc, "Created installation namespace 2") + + set $namespace = installNS2 + set sc = ##class(%IPM.Main).Shell("load " _ packageFileNoTests _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed without test resources") + + do $$$AssertNotTrue(##class(%Dictionary.ClassDefinition).%ExistsId("TestModule.Test"), "Test class excluded by default") + do $$$AssertNotTrue(##class(%Dictionary.ClassDefinition).%ExistsId("TestModule.TestHelper"), "TestHelper class excluded by default") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("TestModule.Main"), "Main class exists") +} + +/// Test install validation (errors and edge cases). The happy path is covered by +/// TestSimplePackageAndInstall, so this test only exercises the rejection cases. +Method TestPackagingAndInstallValidation() +{ + new $namespace + set packageFile = ..GetOrCreateDatabasePackage("simple-db-module") + + // Test: Tampered IRIS.DAT fails checksum validation + set tamperDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "tamper-extract") + do ##class(%Library.File).CreateDirectory(tamperDir) + set sc = ##class(%IPM.General.Archive).Extract(packageFile, tamperDir) + do $$$AssertStatusOK(sc, "Extracted package for tampering test") + + set tamperedIRISDAT = ##class(%Library.File).NormalizeFilename("IRIS.DAT", tamperDir) + do ##class(%Library.File).Delete(tamperedIRISDAT) + set tamperTmp = ##class(%Library.File).TempFilename("dat") + set tamperedStream = ##class(%Stream.FileBinary).%New() + $$$ThrowOnError(tamperedStream.LinkToFile(tamperTmp)) + do tamperedStream.Write("NOT_A_REAL_IRIS_DATABASE_FILE") + $$$ThrowOnError(tamperedStream.%Save()) + if '##class(%Library.File).Rename(tamperTmp, tamperedIRISDAT) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot rename tamper file to IRIS.DAT")) + } + + set tamperedPackage = ..PackageOutputDir _ "simple-db-module-tampered.tgz" + set sc = ##class(%IPM.General.Archive).Create(tamperDir, tamperedPackage) + do $$$AssertStatusOK(sc, "Repacked tampered archive") + + set installNS = "TESTDBVALIDINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created namespace for tampered install test") + set $namespace = installNS + + set sc = ##class(%IPM.Main).Shell("load " _ tamperedPackage _ " -swap-db") + do $$$AssertStatusNotOK(sc, "Tampered IRIS.DAT rejected by checksum validation") + do $$$AssertTrue($system.Status.GetErrorText(sc) [ "SHA-256", "Error mentions SHA-256 checksum mismatch") + do $$$AssertEquals(..CountBackupFiles(installNS), 0, "No DB swap occurred — rejection is pre-swap") + + // Test: Missing deps/ directory fails validation + set missingDepsDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "missing-deps-extract") + do ##class(%Library.File).CreateDirectory(missingDepsDir) + set sc = ##class(%IPM.General.Archive).Extract(packageFile, missingDepsDir) + do $$$AssertStatusOK(sc, "Extracted package for missing deps/ test") + + set missingDepsSubDir = ##class(%Library.File).NormalizeDirectory( + ##class(%Library.File).NormalizeFilename("deps", missingDepsDir)) + do ##class(%Library.File).RemoveDirectoryTree(missingDepsSubDir) + + set missingDepsPackage = ..PackageOutputDir _ "simple-db-module-missing-deps.tgz" + set sc = ##class(%IPM.General.Archive).Create(missingDepsDir, missingDepsPackage) + do $$$AssertStatusOK(sc, "Repacked archive without deps/ directory") + + // Reuses installNS: the tampered install above was rejected by ValidateBeforeSwap, which runs + // entirely before the swap, so that namespace's routines DB is still pristine. + set $namespace = installNS + + set sc = ##class(%IPM.Main).Shell("load " _ missingDepsPackage _ " -swap-db") + do $$$AssertStatusNotOK(sc, "Missing deps/ directory rejected by validation") + do $$$AssertTrue($system.Status.GetErrorText(sc) [ "deps/", "Error mentions missing deps/ directory") + do $$$AssertEquals(..CountBackupFiles(installNS), 0, "No DB swap occurred — rejection is pre-swap") + + // Test: module.xml without a element fails validation. + // The other structural checks in ValidateBeforeSwap (missing IRIS.DAT, missing module.xml, a + // other than "database") cannot be reached through `load`: each one also makes + // IsInstallContext() false, so the install branch is never entered. They remain as + // defense-in-depth for direct %Reload callers and are not exercised here. + set noChecksumDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "no-checksum-extract") + do ##class(%Library.File).CreateDirectory(noChecksumDir) + set sc = ##class(%IPM.General.Archive).Extract(packageFile, noChecksumDir) + do $$$AssertStatusOK(sc, "Extracted package for missing test") + + set noChecksumXML = ##class(%Library.File).NormalizeFilename("module.xml", noChecksumDir) + set xmlStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(xmlStream.LinkToFile(noChecksumXML)) + set moduleXML = "" + while 'xmlStream.AtEnd { + set moduleXML = moduleXML _ xmlStream.Read() + } + // Strip the whole element rather than blanking its text: an empty would fail the + // checksum comparison instead, which the tampering case above already covers. + set checksumStart = $find(moduleXML, " 0, "Packaged module.xml has a element to remove") + set checksumEnd = $find(moduleXML, "") + set moduleXML = $extract(moduleXML, 1, checksumStart - 1) _ $extract(moduleXML, checksumEnd, *) + + $$$ThrowOnError(xmlStream.Clear()) + $$$ThrowOnError(xmlStream.Write(moduleXML)) + $$$ThrowOnError(xmlStream.%Save()) + + set noChecksumPackage = ..PackageOutputDir _ "simple-db-module-no-checksum.tgz" + set sc = ##class(%IPM.General.Archive).Create(noChecksumDir, noChecksumPackage) + do $$$AssertStatusOK(sc, "Repacked archive without ") + + set $namespace = installNS + + set sc = ##class(%IPM.Main).Shell("load " _ noChecksumPackage _ " -swap-db") + do $$$AssertStatusNotOK(sc, "module.xml without rejected by validation") + do $$$AssertTrue($system.Status.GetErrorText(sc) [ "Checksum", "Error mentions the missing element") + do $$$AssertEquals(..CountBackupFiles(installNS), 0, "No DB swap occurred — rejection is pre-swap") +} + +/// Test upgrading from source package to database package +Method TestUpgradeSourceToDatabase() +{ + new $namespace + set dbPackage = ..GetOrCreateDatabasePackage("simple-db-module") + set packagingNS = ..GetOrCreateSharedNS1() + + // Switch to shared packaging namespace (simple-db-module is pre-loaded) + set $namespace = packagingNS + + set sc = ##class(%IPM.Main).Shell("package simple-db-module -path " _ ..PackageOutputDir _ "simple-db-module-1.0.0/") + do $$$AssertStatusOK(sc, "Packaged as source") + + set sourcePackage = ..PackageOutputDir _ "simple-db-module-1.0.0.tgz" + + // Create installation namespace + set installNS = "TESTDBUPGSRCINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created installation namespace") + + set $namespace = installNS + + // Install source package + set sc = ##class(%IPM.Main).Shell("load " _ sourcePackage) + do $$$AssertStatusOK(sc, "Installed source package") + + // Verify source installed + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("SimpleModule.Main"), "Source package installed") + + // Upgrade from source to database package + set sc = ##class(%IPM.Main).Shell("update simple-db-module -path " _ dbPackage _ " -swap-db") + do $$$AssertStatusOK(sc, "Upgraded from source to database package") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("SimpleModule.Main"), "Module still installed after upgrade") + + // Verify module metadata was migrated: packaging type must now be 'database' + set module = ##class(%IPM.Storage.Module).NameOpen("simple-db-module") + do $$$AssertEquals(module.Packaging, "database", "Module packaging changed to 'database' after source-to-database upgrade") + + // Verify a DB swap occurred (at least one backup was created) + do $$$AssertTrue(..CountBackupFiles(installNS) > 0, "DB swap occurred during source-to-database upgrade") +} + +/// Test mixed packaging scenarios +Method TestMixedPackaging() +{ + new $namespace + set dbPackageFile = ..GetOrCreateDatabasePackage("simple-db-module") + set dbPackage2 = ..GetOrCreateDatabasePackage("dep-module") + set packagingNS = ..GetOrCreateSharedNS1() + + // Create installation namespace + set installNS = "TESTDBMIXEDINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created installation namespace") + + set $namespace = installNS + + // Install database package + set sc = ##class(%IPM.Main).Shell("load " _ dbPackageFile _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed database package") + + // Test: Install source package in same namespace should work + // dep-module is pre-loaded in shared NS1; package it as source first (no cache — source flow) + set $namespace = packagingNS + set sc = ##class(%IPM.Main).Shell("package dep-module -path " _ ..PackageOutputDir _ "dep-module-1.0.0/") + do $$$AssertStatusOK(sc, "Packaged as source") + + set sourcePackageFile = ..PackageOutputDir _ "dep-module-1.0.0.tgz" + + set $namespace = installNS + + // Install source package in namespace with database package + set sc = ##class(%IPM.Main).Shell("load " _ sourcePackageFile) + do $$$AssertStatusOK(sc, "Installed source package alongside database package") + + // Verify both packages exist + set cookie = "" + do ##class(%IPM.Utils.Module).BeginCaptureOutput(.cookie) + set sc = ##class(%IPM.Main).Shell("list") + do ##class(%IPM.Utils.Module).EndCaptureOutput(cookie, .output) + + do $$$AssertTrue(..FindStringInMultiDimArray("simple-db-module", .output), "Database package in list") + do $$$AssertTrue(..FindStringInMultiDimArray("dep-module", .output), "Source package in list") + + // Test: Cannot install second database package in same namespace + set $namespace = installNS + + // Attempt to install second database package should fail + set sc = ##class(%IPM.Main).Shell("load " _ dbPackage2 _ " -swap-db") + do $$$AssertStatusNotOK(sc, "Cannot install second database package in same namespace") + do $$$AssertTrue($system.Status.GetErrorText(sc) [ "Only one database package", "Error mentions one-package-per-namespace constraint") +} + +/// A database install replaces the namespace's routines DB wholesale, so any module whose code +/// lives in that DB would lose its classes while its metadata (in the globals DB) survived. +/// Verifies that install is rejected in that case, and that uninstalling the module clears the way. +/// +/// Also verifies the two exemptions: the package's own transitive dependencies (their code is in +/// the incoming IRIS.DAT) and modules with no routines-DB code do not trigger the rejection. +Method TestInstallRejectedWhenModulesWouldBeOrphaned() +{ + set depModuleDir = ..GetModuleDir("db-packaging", "dependency-module") + set noCodeDir = ..GetModuleDir("db-packaging", "module-without-code") + + new $namespace + set dbPackage = ..GetOrCreateDatabasePackage("simple-db-module") + + // main-with-deps depends on dep-module, so dep-module's code ships inside this IRIS.DAT + set withDepsPackage = ..GetOrCreateDatabasePackage("main-with-deps") + + // --- Rejection: a source module with classes in the routines DB blocks the install --- + set installNS = "TESTDBORPHANINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created install namespace") + set $namespace = installNS + + set sc = ##class(%IPM.Main).Shell("load -v " _ depModuleDir) + do $$$AssertStatusOK(sc, "Loaded dep-module from source into install namespace") + + set sc = ##class(%IPM.Main).Shell("load " _ dbPackage _ " -swap-db") + do $$$AssertStatusNotOK(sc, "Install rejected — dep-module's code would be destroyed by the swap") + set errorText = $system.Status.GetErrorText(sc) + do $$$AssertTrue(errorText [ "dep-module", "Error names the module that would be orphaned") + do $$$AssertTrue(errorText [ "uninstall", "Error tells the user to uninstall first") + + // The rejection happens before any swap, so the original DB must be untouched + do $$$AssertEquals(..CountBackupFiles(installNS), 0, "No DB swap occurred — rejection is pre-swap") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepModule.Core"), "dep-module's classes still present after rejection") + + // --- Uninstalling the blocking module clears the way --- + set sc = ##class(%IPM.Main).Shell("uninstall dep-module") + do $$$AssertStatusOK(sc, "Uninstalled dep-module") + + set sc = ##class(%IPM.Main).Shell("load " _ dbPackage _ " -swap-db") + do $$$AssertStatusOK(sc, "Install succeeds once the blocking module is uninstalled") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("SimpleModule.Main"), "Database package installed") + + // --- Exemption: a module with no routines-DB code does not block --- + // module-without-code has only a FileCopy resource, which lives on the filesystem rather + // than in the routines DB, so the swap cannot orphan it. + set installNS2 = "TESTDBORPHANINS2" + set sc = ..CreateTestNamespace(installNS2) + do $$$AssertStatusOK(sc, "Created second install namespace") + set $namespace = installNS2 + + set sc = ##class(%IPM.Main).Shell("load -v " _ noCodeDir) + do $$$AssertStatusOK(sc, "Loaded module-without-code from source") + + set sc = ##class(%IPM.Main).Shell("load " _ dbPackage _ " -swap-db") + do $$$AssertStatusOK(sc, "Module with no routines-DB code does not block the install") + + // --- Exemption: the package's own transitive dependencies do not block --- + // dep-module is installed as source here, but main-with-deps carries its code and metadata, + // so the swap replaces rather than orphans it. + set installNS3 = "TESTDBORPHANINS3" + set sc = ..CreateTestNamespace(installNS3) + do $$$AssertStatusOK(sc, "Created third install namespace") + set $namespace = installNS3 + + set sc = ##class(%IPM.Main).Shell("load -v " _ depModuleDir) + do $$$AssertStatusOK(sc, "Loaded dep-module from source into third namespace") + + set sc = ##class(%IPM.Main).Shell("load " _ withDepsPackage _ " -swap-db") + do $$$AssertStatusOK(sc, "Packaged dependency does not block the install") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepModule.Core"), "dep-module's code came from the packaged IRIS.DAT") +} + +/// Upgrading a database package whose dependency tree changed shape must not be blocked by the +/// orphan check, and dependencies that no longer ship in the new IRIS.DAT must have their stale +/// IPM metadata removed rather than left behind pointing at code that is gone. +/// +/// deptree-parent v1 -> v2 exercises all four kinds of change at once. The comparison is between +/// flattened dependency sets, so re-parenting is invisible to it: +/// +/// v1: deptree-dropped -> deptree-leaf v2: deptree-added -> deptree-leaf +/// deptree-stable 1.0.0 deptree-stable 2.0.0 -> deptree-extra +/// +/// deptree-dropped in v1 only -> dropped dependency, metadata must be cleaned up +/// deptree-added in v2 only -> new direct dependency +/// deptree-leaf in both -> re-parented transitive dependency, must survive untouched +/// deptree-extra in v2 only -> new transitive dependency under an unchanged carrier +/// +/// deptree-leaf and deptree-extra are deliberately reached through different parents than the +/// added/dropped pair so that each feature is isolated: a bug that keys off direct dependencies +/// rather than the flattened set fails on deptree-leaf, and one that ignores transitive additions +/// fails on deptree-extra. +/// +/// deptree-stable also carries an , so the version bump doubles as coverage that a +/// dependency's own update steps are seeded on install and run on upgrade. +Method TestUpdateWithChangedDependencyTree() +{ + // Resolve all module dirs before any namespace switch (^UnitTestRoot only in the original NS) + set leafDir = ..GetModuleDir("db-packaging", "deptree-leaf") + set extraDir = ..GetModuleDir("db-packaging", "deptree-extra") + set droppedDir = ..GetModuleDir("db-packaging", "deptree-dropped") + set addedDir = ..GetModuleDir("db-packaging", "deptree-added") + set stableV1Dir = ..GetModuleDir("db-packaging", "deptree-stable-v1") + set stableV2Dir = ..GetModuleDir("db-packaging", "deptree-stable-v2") + set parentV1Dir = ..GetModuleDir("db-packaging", "deptree-parent-v1") + set parentV2Dir = ..GetModuleDir("db-packaging", "deptree-parent-v2") + + // A dedicated packaging NS rather than a shared one: this test loads two different versions + // of deptree-parent and deptree-stable, which would corrupt the shared namespaces' registries. + set packagingNS = "TESTDBDEPTREEPKG" + do $$$AssertStatusOK(..CreateTestNamespace(packagingNS), "Created packaging namespace") + + new $namespace + set $namespace = packagingNS + + // --- Package v1: deptree-dropped (-> deptree-leaf) and deptree-stable 1.0.0 --- + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v " _ leafDir), "Loaded deptree-leaf") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v " _ droppedDir), "Loaded deptree-dropped") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v " _ stableV1Dir), "Loaded deptree-stable 1.0.0") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v " _ parentV1Dir), "Loaded deptree-parent 1.0.0") + + set sc = ##class(%IPM.Main).Shell("package-database deptree-parent -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged deptree-parent 1.0.0 as database") + set v1Package = ..PackageOutputDir _ "deptree-parent-1.0.0-database.tgz" + do $$$AssertTrue(##class(%Library.File).Exists(v1Package), "v1 database package exists") + + // --- Reshape the tree in the packaging NS, then package v2 --- + // deptree-added and deptree-extra are new; deptree-stable gains the deptree-extra edge at 2.0.0. + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v " _ extraDir), "Loaded deptree-extra") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v " _ addedDir), "Loaded deptree-added") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("update deptree-stable -path " _ stableV2Dir), "Updated deptree-stable to 2.0.0") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("update deptree-parent -path " _ parentV2Dir), "Updated deptree-parent to 2.0.0") + + set sc = ##class(%IPM.Main).Shell("package-database deptree-parent -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged deptree-parent 2.0.0 as database") + set v2Package = ..PackageOutputDir _ "deptree-parent-2.0.0-database.tgz" + do $$$AssertTrue(##class(%Library.File).Exists(v2Package), "v2 database package exists") + + // --- Install v1 into a clean namespace --- + set installNS = "TESTDBDEPTREEINST" + do $$$AssertStatusOK(..CreateTestNamespace(installNS), "Created install namespace") + set $namespace = installNS + + kill ^DBPkgDepTreeSteps + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load " _ v1Package _ " -swap-db"), "Installed deptree-parent 1.0.0") + + // deptree-stable's own update steps: seeded, not run, on a fresh install — its v1 work is + // already baked into the IRIS.DAT, same as for the root module. + do $$$AssertEquals($get(^DBPkgDepTreeSteps("step001"), 0), 0, "Dependency Step001 seeded, not run, during v1 install") + + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Parent"), "Parent class present after v1 install") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Dropped"), "deptree-dropped code in v1 IRIS.DAT") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Leaf"), "deptree-leaf code in v1 IRIS.DAT") + do $$$AssertTrue(##class(%IPM.Storage.Module).NameExists("deptree-dropped"), "deptree-dropped metadata registered from v1 deps/") + do $$$AssertTrue(##class(%IPM.Storage.Module).NameExists("deptree-leaf"), "deptree-leaf metadata registered from v1 deps/") + do $$$AssertNotTrue(##class(%IPM.Storage.Module).NameExists("deptree-added"), "deptree-added absent before upgrade") + do $$$AssertNotTrue(##class(%IPM.Storage.Module).NameExists("deptree-extra"), "deptree-extra absent before upgrade") + + // --- Upgrade to v2 --- + // Every module installed here arrived inside v1's IRIS.DAT, so none of them may trigger the + // orphan rejection — including deptree-dropped, which v2 no longer carries. + set sc = ##class(%IPM.Main).Shell("update deptree-parent -path " _ v2Package _ " -swap-db") + do $$$AssertStatusOK(sc, "Upgrade not blocked by the orphan check despite the reshaped tree") + + // Dropped dependency: code is gone from the new IRIS.DAT, so its metadata must go too + do $$$AssertNotTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Dropped"), "deptree-dropped code absent from v2 IRIS.DAT") + do $$$AssertNotTrue(##class(%IPM.Storage.Module).NameExists("deptree-dropped"), "deptree-dropped metadata cleaned up — no record pointing at missing code") + + // New direct dependency + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Added"), "deptree-added code in v2 IRIS.DAT") + do $$$AssertTrue(##class(%IPM.Storage.Module).NameExists("deptree-added"), "deptree-added metadata registered from v2 deps/") + + // Re-parented transitive dependency: moved from deptree-dropped to deptree-added, must survive + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Leaf"), "deptree-leaf code still present after re-parenting") + do $$$AssertTrue(##class(%IPM.Storage.Module).NameExists("deptree-leaf"), "deptree-leaf metadata retained — re-parenting is not a drop") + + // New transitive dependency under the unchanged carrier + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Extra"), "deptree-extra code in v2 IRIS.DAT") + do $$$AssertTrue(##class(%IPM.Storage.Module).NameExists("deptree-extra"), "deptree-extra metadata registered from v2 deps/") + + // The unchanged carrier itself + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepTree.Stable"), "deptree-stable code present after upgrade") + do $$$AssertTrue(##class(%IPM.Storage.Module).NameExists("deptree-stable"), "deptree-stable metadata retained") + + // A dependency's own update steps run during a database upgrade, matching a source upgrade: + // deptree-stable went 1.0.0 -> 2.0.0, so its new Step002 must run while the seeded Step001 + // stays skipped. Without ApplyUpdateSteps in the dependency phase list both counters stay 0. + do $$$AssertEquals($get(^DBPkgDepTreeSteps("step002"), 0), 1, "Dependency Step002 ran exactly once during the database upgrade") + do $$$AssertEquals($get(^DBPkgDepTreeSteps("step001"), 0), 0, "Dependency Step001 still skipped — seeded at v1 install") + + do $$$AssertTrue(..CountBackupFiles(installNS) > 1, "Second backup created — the DB was swapped again during the upgrade") + + kill ^DBPkgDepTreeSteps +} + +/// Tests all Python dependency packaging behaviour in one pass: +/// - module-with-mixed-python (explicit PythonWheel + requirements.txt): both wheels present +/// by default; both absent with -export-python-deps 0; lune installs correctly at runtime +/// - module-with-requirements (requirements.txt only): wheel present by default; absent with flag +/// Combining these avoids packaging module-with-mixed-python twice across separate test methods. +Method TestPythonDependencyPackaging() +{ + // Capture module dirs before NS switch — GetModuleDir uses ^UnitTestRoot which is only set here + set mixedModuleDir = ..GetModuleDir("db-packaging", "module-with-mixed-python") + set reqModuleDir = ..GetModuleDir("db-packaging", "module-with-requirements") + + set packagingNS = ..GetOrCreateSharedNS2() + new $namespace + set $namespace = packagingNS + + // module-with-mixed-python: default (export-python-deps) + // Packages both an explicit (lune) and a requirements.txt dep (pycparser) + set sc = ##class(%IPM.Main).Shell("package-database module-with-mixed-python -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged module-with-mixed-python with default settings") + + set defaultPkg = ..PackageOutputDir _ "module-with-mixed-python-1.0.0-database.tgz" + set extractDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "extract-mixpy") + do ##class(%Library.File).CreateDirectory(extractDir) + set sc = ##class(%IPM.General.Archive).Extract(defaultPkg, extractDir) + do $$$AssertStatusOK(sc, "Extracted module-with-mixed-python default package") + + set luneWheel = ##class(%Library.File).NormalizeFilename("wheels/lune-1.6.2-py3-none-any.whl", extractDir) + do $$$AssertTrue(##class(%Library.File).Exists(luneWheel), "Explicit PythonWheel (lune) included in default package") + + set wheelsDir = ##class(%Library.File).NormalizeDirectory(##class(%Library.File).NormalizeFilename("wheels", extractDir)) + set pycparserFound = 0 + set rs = ##class(%SQL.Statement).%ExecDirect(, + "SELECT Name FROM %Library.File_FileSet(?, ?, ?, ?)", wheelsDir, "pycparser*.whl", "", 0) + while rs.%Next() { set pycparserFound = 1 } + do $$$AssertTrue(pycparserFound, "requirements.txt wheel (pycparser) included in default package") + + // module-with-mixed-python: -export-python-deps 0 + set noExportDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "noexport") + do ##class(%Library.File).CreateDirectory(noExportDir) + set sc = ##class(%IPM.Main).Shell("package-database module-with-mixed-python -export-python-deps 0 -path " _ noExportDir) + do $$$AssertStatusOK(sc, "Packaged module-with-mixed-python with -export-python-deps 0") + + set noExportPkg = noExportDir _ "module-with-mixed-python-1.0.0-database.tgz" + set extractDir2 = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "extract-nowheel") + do ##class(%Library.File).CreateDirectory(extractDir2) + set sc = ##class(%IPM.General.Archive).Extract(noExportPkg, extractDir2) + do $$$AssertStatusOK(sc, "Extracted no-export package") + + set wheelInNoExport = ##class(%Library.File).NormalizeFilename("wheels/lune-1.6.2-py3-none-any.whl", extractDir2) + do $$$AssertNotTrue(##class(%Library.File).Exists(wheelInNoExport), "Wheel excluded when -export-python-deps 0 is set") + + // Runtime install: PythonWheel.OnPhase("Initialize") fires (no Database.cls override) + set installNS = "TESTDBPYWHLINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created install namespace") + set $namespace = installNS + + do ..PurgePythonPackage("lune") + + set sc = ##class(%IPM.Main).Shell("load " _ defaultPkg _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed database package with Python wheel") + + try { + set lunePackage = ##class(%SYS.Python).Import("lune") + do $$$AssertSuccess("lune importable after database install") + set wheelVer = ..GetPythonVersion("lune") + do $$$AssertEquals(wheelVer, "1.6.2", "lune version is 1.6.2 as packaged") + } catch ex { + do $$$AssertFailure("Failed to import lune after database install: " _ ex.AsStatus()) + } + + // module-with-requirements: default (requirements.txt → wheel) + set $namespace = packagingNS + + set sc = ##class(%IPM.Main).Shell("package-database module-with-requirements -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged module-with-requirements with default settings") + + set reqPkg = ..PackageOutputDir _ "module-with-requirements-1.0.0-database.tgz" + set extractDir3 = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "extract-reqtxt") + do ##class(%Library.File).CreateDirectory(extractDir3) + set sc = ##class(%IPM.General.Archive).Extract(reqPkg, extractDir3) + do $$$AssertStatusOK(sc, "Extracted module-with-requirements default package") + + set wheelPath = ##class(%Library.File).NormalizeFilename("wheels/packaging-25.0-py3-none-any.whl", extractDir3) + do $$$AssertTrue(##class(%Library.File).Exists(wheelPath), "Wheel from requirements.txt included in package") + + // module-with-requirements: -export-python-deps 0 + set noExportReqDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "noexport-reqtxt") + do ##class(%Library.File).CreateDirectory(noExportReqDir) + set sc = ##class(%IPM.Main).Shell("package-database module-with-requirements -export-python-deps 0 -path " _ noExportReqDir) + do $$$AssertStatusOK(sc, "Packaged module-with-requirements with -export-python-deps 0") + + set noExportReqPkg = noExportReqDir _ "module-with-requirements-1.0.0-database.tgz" + set extractDir4 = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "extract-noreqtxt") + do ##class(%Library.File).CreateDirectory(extractDir4) + set sc = ##class(%IPM.General.Archive).Extract(noExportReqPkg, extractDir4) + do $$$AssertStatusOK(sc, "Extracted no-export requirements package") + + set wheelPath2 = ##class(%Library.File).NormalizeFilename("wheels/packaging-25.0-py3-none-any.whl", extractDir4) + do $$$AssertNotTrue(##class(%Library.File).Exists(wheelPath2), "Wheel excluded from requirements package when -export-python-deps 0 is set") + + // Cleanup: wheels downloaded by ExportPythonDependencies during packaging + do ..DeletePattern(mixedModuleDir _ "wheels/", "pycparser*.whl") + do ..DeletePath(reqModuleDir _ "wheels") +} + +/// Test invoke firing behavior during database package install. +/// The DB swap happens inside %Reload, so all invokes from Reload.After onward run +/// with the DB mounted. Both Compile.After and Activate.After should fire. +/// Each invoke sets a global marker so we can verify whether it executed. +Method TestInvokeBehaviorDuringDatabaseInstall() +{ + set packagingNS = ..GetOrCreateSharedNS1() + + // Switch to shared packaging namespace (module-with-invokes is pre-loaded) + // Both invokes fired when the module was loaded during NS1 init — clear lingering markers. + new $namespace + set $namespace = packagingNS + kill ^InvokeTest + + // Package as database + set sc = ##class(%IPM.Main).Shell("package-database module-with-invokes -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged module with invokes") + + set packageFile = ..PackageOutputDir _ "module-with-invokes-1.0.0-database.tgz" + + // Install in a fresh namespace + set installNS = "TESTDBINVKINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created install namespace") + set $namespace = installNS + + // Confirm markers don't exist before install + do $$$AssertEquals($get(^InvokeTest("compile"), 0), 0, "No compile marker before install") + do $$$AssertEquals($get(^InvokeTest("activate"), 0), 0, "No activate marker before install") + + set sc = ##class(%IPM.Main).Shell("load " _ packageFile _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed database package with invokes") + + // Both Compile.After and Activate.After run — DB is mounted after %Reload completes + do $$$AssertEquals($get(^InvokeTest("compile")), 1, "Compile invoke ran during database install") + do $$$AssertEquals($get(^InvokeTest("activate")), 1, "Activate invoke ran during database install") + + // Verify module classes are accessible (DB was mounted) + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("InvokeModule.Main"), "InvokeModule.Main class exists") + + // Cleanup + kill ^InvokeTest +} + +/// Proves that non-compiled resource processor hooks (CPF, FileCopy) actually fire during +/// a database package install, not just during the original source load. +/// Uses a delete-then-verify pattern for FileCopy: the file is deleted after the initial load, +/// then install is run — if FileCopy fires, the file reappears. CPF staging is verified by +/// extracting the package and confirming the CPF file is present. +/// +/// Distinct from TestPackageAndInstallAllResourceTypes: that test checks compiled outputs +/// (classes, include, WebApp) on a full-featured module. This test uses a minimal module +/// (class + CPF + FileCopy only) to isolate and prove the processor hook firing mechanism, +/// with explicit before/after assertions rather than a passive smoke test. +Method TestNonCompiledResourcesAppliedDuringInstall() +{ + set packagingNS = ..GetOrCreateSharedNS2() + + // Switch to shared packaging namespace (module-with-non-compiled-resources is pre-loaded; + // FileCopy already ran during NS2 init so the target file exists) + new $namespace + set $namespace = packagingNS + + // Package as database + set sc = ##class(%IPM.Main).Shell("package-database module-with-non-compiled-resources -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged module as database") + + set packageFile = ..PackageOutputDir _ "module-with-non-compiled-resources-1.0.0-database.tgz" + + // Verify CPF file is staged in the package + set cpfExtractDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "cpf-staging-check") + do ##class(%Library.File).CreateDirectory(cpfExtractDir) + set sc = ##class(%IPM.General.Archive).Extract(packageFile, cpfExtractDir) + do $$$AssertStatusOK(sc, "Extracted package for CPF staging check") + set stagedCPF = ##class(%Library.File).NormalizeFilename("src/cpf/config.cpf", cpfExtractDir) + do $$$AssertTrue(##class(%Library.File).Exists(stagedCPF), "CPF file is staged in database package") + + // Compute FileCopy target: ${mgrdir}../non-compiled-data.txt + // ParentDirectoryName handles the .. reliably across platforms + set mgrDir = ##class(%Library.File).ManagerDirectory() + set parentDir = ##class(%Library.File).ParentDirectoryName(mgrDir) + set fileCopyTarget = ##class(%Library.File).NormalizeFilename("non-compiled-data.txt", parentDir) + + // Confirm FileCopy created the file during load, then delete it + do $$$AssertTrue(##class(%Library.File).Exists(fileCopyTarget), "FileCopy target exists after load (pre-condition)") + set deleteOK = ##class(%Library.File).Delete(fileCopyTarget) + do $$$AssertTrue(deleteOK, "FileCopy target successfully deleted before install (setup check)") + do $$$AssertNotTrue(##class(%Library.File).Exists(fileCopyTarget), "FileCopy target absent before install (setup verified)") + + // Create install namespace + set installNS = "TESTDBNONCMPINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created install namespace") + set $namespace = installNS + + set sc = ##class(%IPM.Main).Shell("load " _ packageFile _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed database package (CPF applied during Initialize, FileCopy during Activate)") + + // CPF.OnBeforePhase("Initialize") and FileCopy.OnBeforePhase("Activate") are both invoked + // by Module.RunPhase independently of the lifecycle method — resource processor hooks are + // siblings, not children. CPF staging is verified above; FileCopy is verified here. + do $$$AssertTrue(##class(%Library.File).Exists(fileCopyTarget), "FileCopy target created during database install") +} + +/// Test that update steps are seeded (not run) on a fresh database package install, and that +/// new steps introduced in a later version run exactly once during a database package upgrade. +/// +/// Flow: +/// 1. Load v1 from source in a dedicated packaging NS; package-database v1. +/// 2. Upgrade to v2 from source in the same NS; package-database v2. +/// 3. In a fresh install NS: install v1 database package — Step001 must be seeded, not run. +/// 4. Upgrade to v2 database package — Step001 must still not have run; Step002 must run once. +/// +/// Uses a dedicated per-test packaging NS (not SharedNS1/2) because it is mutated by the +/// v1→v2 source upgrade. The packaging NS's update step side effects (Step002 executes there +/// during the source upgrade) do not affect the install NS — ^DBPkgUpdateSteps is a global and +/// lives in each namespace's own globals DB. +Method TestUpdateStepsWithDatabasePackaging() +{ + set v1Dir = ..GetModuleDir("db-packaging", "module-with-update-steps-v1") + set v2Dir = ..GetModuleDir("db-packaging", "module-with-update-steps-v2") + + // --- Packaging phase: load both versions from source and database-package each --- + set packagingNS = "TESTDBUPDPKGNS" + set sc = ..CreateTestNamespace(packagingNS) + do $$$AssertStatusOK(sc, "Created packaging namespace") + + new $namespace + set $namespace = packagingNS + + // Fresh install of v1 from source: Module.cls:%Activate seeds Step001 in packaging NS globals + set sc = ##class(%IPM.Main).Shell("load -v " _ v1Dir) + do $$$AssertStatusOK(sc, "Loaded v1 from source in packaging NS") + + set sc = ##class(%IPM.Main).Shell("package-database update-steps-module -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged update-steps-module v1 as database") + set v1Package = ..PackageOutputDir _ "update-steps-module-1.0.0-database.tgz" + do $$$AssertTrue(##class(%Library.File).Exists(v1Package), "v1 database package .tgz exists") + + // Source upgrade to v2: Step001 already seeded so it is skipped; Step002 runs in packaging NS + set sc = ##class(%IPM.Main).Shell("update update-steps-module -path " _ v2Dir) + do $$$AssertStatusOK(sc, "Upgraded to v2 from source in packaging NS") + + set sc = ##class(%IPM.Main).Shell("package-database update-steps-module -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged update-steps-module v2 as database") + set v2Package = ..PackageOutputDir _ "update-steps-module-2.0.0-database.tgz" + do $$$AssertTrue(##class(%Library.File).Exists(v2Package), "v2 database package .tgz exists") + + // --- Install phase: fresh namespace, verify seeding and selective step execution --- + set installNS = "TESTDBUPDINSTNS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created install namespace") + + set $namespace = installNS + + // Install v1 database package. + // Database.cls:%Reload swaps the DB then seeds all update steps — Step001 must be + // seeded (not executed) because its work is already baked into the IRIS.DAT. + set sc = ##class(%IPM.Main).Shell("load " _ v1Package _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed v1 database package") + + do $$$AssertEquals($get(^DBPkgUpdateSteps("step001"), 0), 0, "Step001 not run after v1 database install (seeded only)") + do $$$AssertEquals($get(^DBPkgUpdateSteps("step002"), 0), 0, "Step002 absent in v1 — counter is 0") + + // Upgrade to v2 database package. + // Database.cls:%Reload swaps the DB (skips seeding — update path); + // %ApplyUpdateSteps then runs HandleAllUpdateSteps: Step001 is already seeded so it is + // skipped; Step002 is new and must run exactly once. + set sc = ##class(%IPM.Main).Shell("update update-steps-module -path " _ v2Package _ " -swap-db") + do $$$AssertStatusOK(sc, "Upgraded to v2 database package") + + do $$$AssertEquals($get(^DBPkgUpdateSteps("step001"), 0), 0, "Step001 still not run after v2 upgrade (seeded at v1 install)") + do $$$AssertEquals($get(^DBPkgUpdateSteps("step002"), 0), 1, "Step002 ran exactly once during v2 database upgrade") + do $$$AssertTrue(..CountBackupFiles(installNS) > 1, "Second backup created during v2 upgrade (DB was swapped again)") + + kill ^DBPkgUpdateSteps +} + +/// Verifies packaging-type-aware registry filtering via zot (http://oras:5000). +/// Publishes dep-module as both source and database, and main-with-deps as source only. +/// Then verifies: source install (no -swap-db), database install (-swap-db), dependency +/// resolution always picks source, and a helpful error is returned when only a database +/// package is available and -swap-db is not specified. +Method TestPublishDatabaseWithORAS() +{ + // dep-module and main-with-deps are pre-loaded in NS1; zot is configured there at NS init time. + // This test focuses on registry-specific packaging-type filtering behavior. + set packagingNS = ..GetOrCreateSharedNS1() + + // Create a fresh install namespace for all install steps + set installNS = "TESTDBORASINSTNS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created ORAS install namespace") + + new $namespace + set $namespace = installNS + set sc = ##class(%IPM.Main).Shell("repo -o -name zot -url http://oras:5000") + do $$$AssertStatusOK(sc, "Configured zot registry in install NS") + + // --- Pre-test cleanup: purge any stale versions from a prior run --- + set $namespace = packagingNS + do ##class(%IPM.Main).Shell("unpublish zot/dep-module all -f") + do ##class(%IPM.Main).Shell("unpublish zot/main-with-deps all -f") + + // --- Publish phase (all publishes first, then installs) --- + + // Step 1: publish dep-module as source to zot + set sc = ##class(%IPM.Main).Shell("publish dep-module -r zot") + do $$$AssertStatusOK(sc, "Published dep-module as source to zot") + + // Step 2: publish dep-module as database to zot + set sc = ##class(%IPM.Main).Shell("publish-database dep-module -r zot") + do $$$AssertStatusOK(sc, "Published dep-module as database to zot") + + // Step 3: publish main-with-deps as source to zot (exercises dependency filtering path) + set sc = ##class(%IPM.Main).Shell("publish main-with-deps -r zot") + do $$$AssertStatusOK(sc, "Published main-with-deps as source to zot") + + // --- Install phase --- + set $namespace = installNS + + // Step 4: install without -swap-db → should install source package + set sc = ##class(%IPM.Main).Shell("install dep-module") + do $$$AssertStatusOK(sc, "Installed dep-module (source) without -swap-db") + set mod = ##class(%IPM.Storage.Module).NameOpen("dep-module", , .modSC) + do $$$AssertStatusOK(modSC, "dep-module record accessible after source install") + do $$$AssertEquals(mod.Packaging, "module", "dep-module packaging is module (source) after non-swap-db install") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepModule.Core"), "DepModule.Core class exists after source install") + set sc = ##class(%IPM.Main).Shell("uninstall dep-module -f") + do $$$AssertStatusOK(sc, "Uninstalled dep-module before next step") + + // Step 5: install with -swap-db → should install database package + set sc = ##class(%IPM.Main).Shell("install dep-module -swap-db") + do $$$AssertStatusOK(sc, "Installed dep-module with -swap-db") + set mod = ##class(%IPM.Storage.Module).NameOpen("dep-module", , .modSC) + do $$$AssertStatusOK(modSC, "dep-module record accessible after database install") + do $$$AssertEquals(mod.Packaging, "database", "dep-module packaging is database after -swap-db install") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepModule.Core"), "DepModule.Core class exists after database install") + set sc = ##class(%IPM.Main).Shell("uninstall dep-module -f") + do $$$AssertStatusOK(sc, "Uninstalled dep-module (database) before next step") + + // Step 6: install main-with-deps → dep-module should resolve as source + set sc = ##class(%IPM.Main).Shell("install main-with-deps") + do $$$AssertStatusOK(sc, "Installed main-with-deps (source)") + set depMod = ##class(%IPM.Storage.Module).NameOpen("dep-module", , .depModSC) + do $$$AssertStatusOK(depModSC, "dep-module record accessible after main-with-deps install") + do $$$AssertEquals(depMod.Packaging, "module", "dep-module resolved as source when installing main-with-deps") + set sc = ##class(%IPM.Main).Shell("uninstall main-with-deps -f") + do $$$AssertStatusOK(sc, "Uninstalled main-with-deps") + set sc = ##class(%IPM.Main).Shell("uninstall dep-module -f") + do $$$AssertStatusOK(sc, "Uninstalled dep-module after main-with-deps test") + + // Step 7: unpublish only the source version of dep-module, leaving database tag intact + set $namespace = packagingNS + set sc = ##class(%IPM.Main).Shell("unpublish zot/dep-module 1.0.0 -f") + do $$$AssertStatusOK(sc, "Unpublished source version of dep-module from zot") + + // Step 8: install without -swap-db → should fail with helpful error pointing to -swap-db + set $namespace = installNS + set sc = ##class(%IPM.Main).Shell("install dep-module") + do $$$AssertTrue($$$ISERR(sc), "Install dep-module without -swap-db fails when only database package available") + do $$$AssertTrue($system.Status.GetErrorText(sc) [ "-swap-db", "Error message references -swap-db flag") + + // --- Cleanup --- + // Unpublish all remaining versions of dep-module and main-with-deps from zot + set $namespace = packagingNS + set sc = ##class(%IPM.Main).Shell("unpublish zot/dep-module all -f") + // Ignore error — database tag may already be gone if step 7 deleted all + set sc = ##class(%IPM.Main).Shell("unpublish zot/main-with-deps all -f") + // Ignore error — already cleaned up above +} + +/// Verifies that package-database overwrites an existing SystemRequirements Version +/// with the current IRIS version and emits a warning when the versions differ. +Method TestSystemRequirementsOverwrite() +{ + set packagingNS = ..GetOrCreateSharedNS1() + + // Build a previous-major IRIS version string dynamically to avoid hardcoding invalid values + set prevIrisVersion = ($system.Version.GetMajor() - 1) _ "." _ $system.Version.GetMinor() + set currentIrisVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + + // Read XData template before switching namespaces — %Dictionary.CompiledXData looks up + // class metadata in the current namespace, and the test class is not compiled in packagingNS. + set xData = ##class(%Dictionary.CompiledXData).IDKEYOpen($classname(), "SimpleModuleWithSysReqTemplate", , .sc) + $$$ThrowOnError(sc) + set rawXML = "" + while 'xData.Data.AtEnd { + set rawXML = rawXML _ xData.Data.Read() + } + set rawXML = $replace(rawXML, "REPLACEPREVIRISVERSION", prevIrisVersion) + + new $namespace + set $namespace = packagingNS + + // simple-db-module is already loaded in NS1. Temporarily overwrite its on-disk module.xml + // with one that declares the previous IRIS version in SystemRequirements, package it, + // then restore the original. This avoids re-loading (which would enforce SystemRequirements). + set mod = ##class(%IPM.Storage.Module).NameOpen("simple-db-module", , .sc) + $$$ThrowOnError(sc) + set moduleXMLPath = ##class(%Library.File).NormalizeFilename("module.xml", mod.Root) + + // Read and preserve the original module.xml + set origStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(origStream.LinkToFile(moduleXMLPath)) + set origXML = origStream.Read(, .sc) + $$$ThrowOnError(sc) + + // Mutate module.xml then run packaging inside a try/catch so that a write failure + // still reaches the restore block below — the file may be partially written. + set sc = $$$OK + try { + set writeStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(writeStream.LinkToFile(moduleXMLPath)) + $$$ThrowOnError(writeStream.Clear()) + $$$ThrowOnError(writeStream.Write(rawXML)) + $$$ThrowOnError(writeStream.%Save()) + + // Package as database — should overwrite SystemRequirements Version with current IRIS version + set sc = ##class(%IPM.Main).Shell("package-database simple-db-module -path " _ ..PackageOutputDir) + } catch ex { + set sc = ex.AsStatus() + } + + // Restore original module.xml unconditionally — writing origXML over an untouched file + // (if the mutation write never completed) is harmless. Protects SharedNS1 in all paths. + try { + set restoreStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(restoreStream.LinkToFile(moduleXMLPath)) + $$$ThrowOnError(restoreStream.Clear()) + $$$ThrowOnError(restoreStream.Write(origXML)) + $$$ThrowOnError(restoreStream.%Save()) + } catch restoreEx { + set sc = $$$ADDSC(sc, restoreEx.AsStatus()) + } + + do $$$AssertStatusOK(sc, "Packaged simple-db-module with overwrite of SystemRequirements Version") + + // Extract and read the packaged module.xml + set packageFile = ..PackageOutputDir _ "simple-db-module-1.0.0-database.tgz" + set extractDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "extract-sysreq") + if '##class(%Library.File).CreateDirectory(extractDir) { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Failed to create extract dir: " _ extractDir)) + } + set sc = ##class(%IPM.General.Archive).Extract(packageFile, extractDir) + do $$$AssertStatusOK(sc, "Extracted package for SystemRequirements verification") + + set readStream = ##class(%Stream.FileCharacter).%New() + $$$ThrowOnError(readStream.LinkToFile(extractDir _ "module.xml")) + // Read the whole file: a single Read() stops at the chunk size, and the negative assertion + // below would pass merely because the truncated text never reached the version element. + set pkgXML = "" + while 'readStream.AtEnd { + set pkgXML = pkgXML _ readStream.Read() + } + + set hasCurrentVersion = (pkgXML [ ("Version=""" _ currentIrisVersion _ """")) + set lacksPrevVersion = '(pkgXML [ ("Version=""" _ prevIrisVersion _ """")) + do $$$AssertTrue(hasCurrentVersion, "Packaged module.xml has current IRIS version in SystemRequirements") + do $$$AssertTrue(lacksPrevVersion, "Packaged module.xml does not contain previous IRIS version") + + // Cleanup + do ##class(%IPM.Utils.File).RemoveDirectoryTree(extractDir) +} + +/// Get the installed version of a Python package. +ClassMethod GetPythonVersion(name As %String) As %String +{ + try { + set importlib = ##class(%SYS.Python).Import("importlib") + set metadata = importlib."import_module"("importlib.metadata") + set ver = metadata."version"(name) + return ver + } catch ex { + set packageResources = ##class(%SYS.Python).Import("pkg_resources") + set dist = packageResources."get_distribution"(name) + return dist."version" + } +} + +/// Remove a file or directory if it exists. +ClassMethod DeletePath(path As %String) +{ + if ##class(%Library.File).DirectoryExists(path) { + set removeSuccess = ##class(%Library.File).RemoveDirectoryTree(path) + if 'removeSuccess { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Failed to remove directory: " _ path)) + } + } elseif ##class(%Library.File).Exists(path) { + set removeSuccess = ##class(%Library.File).Delete(path) + if 'removeSuccess { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Failed to remove file: " _ path)) + } + } +} + +/// Remove all files under folder matching a glob pattern. +ClassMethod DeletePattern( + folder As %String, + pattern As %String) +{ + set file = $zsearch(folder _ pattern) + while file '= "" { + do ..DeletePath(file) + set file = $zsearch("") + } +} + +/// Counts IRIS__*.DAT backup files in the routines DB directory for a namespace. +/// The last FileSet argument must be 0: with directories included they are returned regardless +/// of the wildcard, so the DB directory's own subdirectories (e.g. stream) would be counted. +Method CountBackupFiles(ns As %String) As %Integer +{ + set routinesDBName = ##class(%IPM.Lifecycle.Database).GetRoutinesDBName(ns) + set dbDir = ##class(%IPM.Lifecycle.Database).GetDatabaseDirectory(routinesDBName) + set rs = ##class(%SQL.Statement).%ExecDirect(, + "SELECT COUNT(*) AS fileCount FROM %Library.File_FileSet(?, ?, ?, ?)", + dbDir, "IRIS_" _ routinesDBName _ "_*.DAT", "", 0) + if rs.%SQLCODE < 0 { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Error counting backup files: " _ rs.%Message)) + } + do rs.%Next() + quit rs.%Get("fileCount") +} + +/// Removes all installed files of a Python package from the shared pip target directory. +/// Deletes both the package directory and its .dist-info metadata directory. +/// Uses direct file deletion rather than pip uninstall to avoid pip dependency resolution +/// and ensure a clean slate regardless of pip state. +ClassMethod PurgePythonPackage(name As %String) +{ + set target = ##class(%Library.File).NormalizeDirectory("python", ##class(%Library.File).ManagerDirectory()) + do ..DeletePath(target _ name) + do ..DeletePattern(target, name _ "-*" _ ".dist-info") +} + +/// Simulates a swap that failed before the original IRIS.DAT was renamed to a backup: +/// the routines DB directory still holds the original IRIS.DAT and no backup file exists. +/// RollbackDatabaseSwap must not delete the original when there is no backup to restore. +Method TestRollbackPreservesOriginalWhenNoBackup() +{ + // Create a throwaway namespace so we have a real, mounted routines DB directory to act on. + set installNS = "TESTDBROLLBACKNS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created test namespace") + + // Resolve the routines DB directory for the namespace. + set dbDir = ##class(%IPM.Lifecycle.Database).GetDatabaseDirectory(##class(%IPM.Lifecycle.Database).GetRoutinesDBName(installNS)) + set irisDAT = dbDir _ "IRIS.DAT" + do $$$AssertTrue(##class(%Library.File).Exists(irisDAT), "Original IRIS.DAT exists before rollback") + + // Invoke rollback with a backupPath that does not exist. + set bogusBackup = dbDir _ "IRIS_nonexistent_backup.DAT" + do $$$AssertNotTrue(##class(%Library.File).Exists(bogusBackup), "Backup file intentionally absent") + set lifecycle = ##class(%IPM.Lifecycle.Database).%New(##class(%IPM.Storage.Module).%New()) + do lifecycle.RollbackDatabaseSwap(dbDir, bogusBackup) + + // The original must survive. + do $$$AssertTrue(##class(%Library.File).Exists(irisDAT), "Original IRIS.DAT preserved when no backup exists") +} + +/// Template module.xml for TestSystemRequirementsOverwrite. +/// REPLACEPREVIRISVERSION is substituted at runtime with a dynamically computed previous IRIS version. +XData SimpleModuleWithSysReqTemplate +{ + + + + + simple-db-module + 1.0.0 + module + src + + + + + + +} + +/// The checksum embedded in a packaged module.xml must equal the hash of the IRIS.DAT that +/// actually ships in the .tgz — verified for the -use-current-db copy flow. +Method TestChecksumMatchesShippedDat() +{ + set packagingNS = ..GetOrCreateSharedNS1() + new $namespace + set $namespace = packagingNS + set sc = ##class(%IPM.Main).Shell("package-database simple-db-module -use-current-db -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged module with -use-current-db") + set packageFile = ..PackageOutputDir _ "simple-db-module-1.0.0-database.tgz" + do $$$AssertTrue(##class(%Library.File).Exists(packageFile), "Package file exists") + + // Extract into a temp dir. + set extractDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "extract-checksum") + do ##class(%Library.File).CreateDirectory(extractDir) + do $$$AssertStatusOK(##class(%IPM.General.Archive).Extract(packageFile, extractDir), "Extracted package") + + // Read the embedded checksum from module.xml (string match, matching this class's other tests). + set stream = ##class(%Stream.FileCharacter).%New() + do $$$AssertStatusOK(stream.LinkToFile(extractDir _ "module.xml"), "Linked module.xml") + set xmlContent = stream.Read() + // Extract the value between and . + set marker = "" + set afterOpen = $piece(xmlContent, marker, 2) + set embedded = $piece(afterOpen, "", 1) + do $$$AssertTrue(embedded '= "", "Embedded checksum found in module.xml") + + // Hash the shipped IRIS.DAT and compare. + set actual = ##class(%IPM.Lifecycle.Database).ComputeSHA256Hex(extractDir _ "IRIS.DAT") + do $$$AssertEquals($zconvert(actual, "L"), $zconvert(embedded, "L"), "Embedded checksum matches shipped IRIS.DAT") + + do ##class(%IPM.Utils.File).RemoveDirectoryTree(extractDir) +} + +/// A dependency's non-compiled and config-only resources must be applied during a database +/// install, not silently dropped: FileCopy, CPF, requirements.txt wheels, Generated resources and +/// elements all belong to the dependency, whose lifecycle phases run after the swap. +/// +/// Also pins Initialize-phase precedence. Both modules merge a CPF touching the same setting; the +/// dependency's merge happens after the root's Initialize has already passed, so the root +/// re-asserts its own merge afterwards and its value must be the one that survives. +Method TestDependencyResourcesAppliedDuringInstall() +{ + set packagingNS = ..GetOrCreateSharedNS2() + + new $namespace + set $namespace = packagingNS + + set sc = ##class(%IPM.Main).Shell("package-database main-with-resource-deps -path " _ ..PackageOutputDir) + do $$$AssertStatusOK(sc, "Packaged main-with-resource-deps as database") + + set packageFile = ..PackageOutputDir _ "main-with-resource-deps-1.0.0-database.tgz" + do $$$AssertTrue(##class(%Library.File).Exists(packageFile), "Database package .tgz exists") + + // --- Package contents: dependency files must be staged under deps-files// --- + set extractDir = ##class(%Library.File).NormalizeDirectory(..PackageOutputDir _ "dep-staging-check") + do ##class(%Library.File).CreateDirectory(extractDir) + set sc = ##class(%IPM.General.Archive).Extract(packageFile, extractDir) + do $$$AssertStatusOK(sc, "Extracted package for staging check") + + set depStageDir = ##class(%IPM.Lifecycle.Database).GetDependencyStagingDir(extractDir, "dep-with-resources") + do $$$AssertTrue(##class(%Library.File).Exists(##class(%Library.File).NormalizeFilename("dep-data.txt", depStageDir)), "Dependency FileCopy source staged under deps-files/") + do $$$AssertTrue(##class(%Library.File).Exists(##class(%Library.File).NormalizeFilename("src/cpf/dep.cpf", depStageDir)), "Dependency CPF staged under deps-files/") + + // Dependency requirements.txt wheels are staged and declared in the dependency's manifest, + // never in the packaging machine's installed .ZPM. + set wheelRS = ##class(%SQL.Statement).%ExecDirect(, "SELECT ItemName FROM %Library.File_FileSet(?, ?, ?, ?)", ##class(%Library.File).NormalizeDirectory("wheels", depStageDir), "six*.whl", "", 0) + do $$$AssertEquals(wheelRS.%SQLCODE, 0, "Listed staged dependency wheels") + do $$$AssertTrue(wheelRS.%Next(), "Dependency requirements.txt wheel (six) staged under deps-files/") + + set depManifest = ##class(%Library.File).NormalizeFilename("dep-with-resources.xml", ##class(%Library.File).NormalizeDirectory("deps", extractDir)) + set manifestStream = ##class(%Stream.FileCharacter).%New() + do $$$AssertStatusOK(manifestStream.LinkToFile(depManifest), "Opened dependency manifest") + set manifestText = "" + while 'manifestStream.AtEnd { + set manifestText = manifestText _ manifestStream.Read(16000) + } + do $$$AssertTrue(manifestText [ "PythonWheel", "Dependency manifest declares an injected ") + + // --- Clear pre-existing side effects so the install is what creates them --- + set mgrDir = ##class(%Library.File).ManagerDirectory() + set parentDir = ##class(%Library.File).ParentDirectoryName(mgrDir) + set depCopyTarget = ##class(%Library.File).NormalizeFilename("dep-res-data.txt", parentDir) + set mainCopyTarget = ##class(%Library.File).NormalizeFilename("main-res-data.txt", parentDir) + do ..DeletePath(depCopyTarget) + do ..DeletePath(mainCopyTarget) + do $$$AssertNotTrue(##class(%Library.File).Exists(depCopyTarget), "Dependency FileCopy target absent before install") + + // --- Install --- + set installNS = "TESTDBDEPRESINS" + set sc = ..CreateTestNamespace(installNS) + do $$$AssertStatusOK(sc, "Created install namespace") + set $namespace = installNS + + kill ^DBPkgDepInvoked + set sc = ##class(%IPM.Main).Shell("load " _ packageFile _ " -swap-db") + do $$$AssertStatusOK(sc, "Installed main-with-resource-deps database package") + + // Compiled code for both modules, and the dependency's Generated resource, come from IRIS.DAT + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("MainRes.App"), "MainRes.App present") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepRes.Core"), "DepRes.Core present") + do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("DepRes.Generated"), "Dependency Generated resource present") + do $$$AssertEquals(##class(DepRes.Generated).GetGeneratedValue(), "Dependency generated value", "Dependency Generated class works") + + // Dependency non-compiled resources applied by the post-swap dependency pass + do $$$AssertTrue(##class(%Library.File).Exists(depCopyTarget), "Dependency FileCopy applied during install") + do $$$AssertTrue(##class(%Library.File).Exists(mainCopyTarget), "Root FileCopy applied during install") + + // Dependency at Activate.After ran exactly once + do $$$AssertEquals($get(^DBPkgDepInvoked, 0), 1, "Dependency ran exactly once during install") + + // Root CPF value wins: the dependency merged after the root's Initialize, so the root + // re-asserted its own merge afterwards. + do ..AssertGlobals8KB(170000, "Root CPF value survives — re-asserted after the dependency merge") + + kill ^DBPkgDepInvoked + do ..DeletePath(depCopyTarget) + do ..DeletePath(mainCopyTarget) + do ##class(%IPM.Utils.File).RemoveDirectoryTree(extractDir) +} + +/// Asserts the [config] globals 8KB-buffer setting both CPF fixtures target. +/// Read-back property name matches Test.PM.Integration.CPFMerge:45. +/// A CPF merge is instance-wide, not namespace-scoped, so this test leaves globals8kb at the +/// root fixture's value for whatever runs next. Test.PM.Integration.CPFMerge asserts its own +/// value after its own merge, so it is unaffected by run order — but any new test that reads +/// globals8kb without merging first must not assume the instance default. +Method AssertGlobals8KB( + expected As %Integer, + message As %String) +{ + new $namespace + set $namespace = "%SYS" + do $$$AssertStatusOK(##class(Config.config).Get(.props), "Read [config] section") + do $$$AssertEquals(props("globals8kb"), expected, message) +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/Resources.cls b/tests/integration_tests/Test/PM/Integration/Resources.cls index 9ebe7526c..87ff9c074 100644 --- a/tests/integration_tests/Test/PM/Integration/Resources.cls +++ b/tests/integration_tests/Test/PM/Integration/Resources.cls @@ -3,6 +3,9 @@ Class Test.PM.Integration.Resources Extends Test.PM.Integration.Base Parameter NEEDSREGISTRY = 0; +/// The resource-test modules include DTL and BPL resources. +Parameter NEEDSINTEROP = 1; + Parameter ResourceTestIRISHealthMod = "ResourceTestIRISHealth"; Parameter ResourceTestIRISMod = "ResourceTestIRIS"; diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/dep-data.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/dep-data.txt new file mode 100644 index 000000000..6e2797b0a --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/dep-data.txt @@ -0,0 +1 @@ +dependency file copy payload diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/module.xml new file mode 100644 index 000000000..db4b61863 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/module.xml @@ -0,0 +1,18 @@ + + + + + dep-with-resources + 1.0.0 + module + src + + + + + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/requirements.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/requirements.txt new file mode 100644 index 000000000..0e2ed8b61 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/requirements.txt @@ -0,0 +1 @@ +six==1.17.0 diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cls/DepRes/Core.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cls/DepRes/Core.cls new file mode 100644 index 000000000..794c4f195 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cls/DepRes/Core.cls @@ -0,0 +1,9 @@ +Class DepRes.Core +{ + +ClassMethod GetVersion() As %String +{ + return "1.0.0" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cls/DepRes/Generator.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cls/DepRes/Generator.cls new file mode 100644 index 000000000..42f354d78 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cls/DepRes/Generator.cls @@ -0,0 +1,41 @@ +Class DepRes.Generator +{ + +/// Generates DepRes.Generated. Invoked After Compile so this generator is already compiled. +/// Idempotent: deletes any existing definition first. +ClassMethod Generate() As %Status +{ + set sc = $$$OK + try { + if ##class(%Dictionary.ClassDefinition).%ExistsId("DepRes.Generated") { + $$$ThrowOnError(##class(%Dictionary.ClassDefinition).%DeleteId("DepRes.Generated")) + } + + set cls = ##class(%Dictionary.ClassDefinition).%New() + set cls.Name = "DepRes.Generated" + + set meth = ##class(%Dictionary.MethodDefinition).%New() + set meth.Name = "GetGeneratedValue" + set meth.ClassMethod = 1 + set meth.ReturnType = "%String" + set impl = ##class(%Stream.TmpCharacter).%New() + do impl.Write(" return ""Dependency generated value""") + set meth.Implementation = impl + do cls.Methods.Insert(meth) + + $$$ThrowOnError(cls.%Save()) + $$$ThrowOnError($system.OBJ.Compile("DepRes.Generated", "ck-d")) + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +/// Marks that an in a dependency ran during a database install. +ClassMethod MarkInvoked() As %Status +{ + set ^DBPkgDepInvoked = $increment(^DBPkgDepInvoked) + return $$$OK +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cpf/dep.cpf b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cpf/dep.cpf new file mode 100644 index 000000000..930da7c39 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/src/cpf/dep.cpf @@ -0,0 +1,2 @@ +[config] +globals=0,0,120000,0,0,0 diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/wheels/six-1.17.0-py2.py3-none-any.whl b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/wheels/six-1.17.0-py2.py3-none-any.whl new file mode 100644 index 000000000..c506fd05b Binary files /dev/null and b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dep-with-resources/wheels/six-1.17.0-py2.py3-none-any.whl differ diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dependency-module/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dependency-module/module.xml new file mode 100644 index 000000000..3c7a16f6b --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dependency-module/module.xml @@ -0,0 +1,12 @@ + + + + + dep-module + 1.0.0 + module + src + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dependency-module/src/cls/DepModule/Core.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dependency-module/src/cls/DepModule/Core.cls new file mode 100644 index 000000000..7aa85b66e --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/dependency-module/src/cls/DepModule/Core.cls @@ -0,0 +1,9 @@ +Class DepModule.Core +{ + +ClassMethod GetVersion() As %String +{ + return "1.0.0" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-added/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-added/module.xml new file mode 100644 index 000000000..f38b77022 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-added/module.xml @@ -0,0 +1,18 @@ + + + + + deptree-added + 1.0.0 + module + src + + + deptree-leaf + 1.0.0 + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-added/src/cls/DepTree/Added.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-added/src/cls/DepTree/Added.cls new file mode 100644 index 000000000..63255b37f --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-added/src/cls/DepTree/Added.cls @@ -0,0 +1,9 @@ +Class DepTree.Added +{ + +ClassMethod GetName() As %String +{ + return "deptree-added" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-dropped/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-dropped/module.xml new file mode 100644 index 000000000..a605c1b06 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-dropped/module.xml @@ -0,0 +1,18 @@ + + + + + deptree-dropped + 1.0.0 + module + src + + + deptree-leaf + 1.0.0 + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-dropped/src/cls/DepTree/Dropped.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-dropped/src/cls/DepTree/Dropped.cls new file mode 100644 index 000000000..7a3ce7b03 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-dropped/src/cls/DepTree/Dropped.cls @@ -0,0 +1,9 @@ +Class DepTree.Dropped +{ + +ClassMethod GetName() As %String +{ + return "deptree-dropped" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-extra/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-extra/module.xml new file mode 100644 index 000000000..e34dc8b58 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-extra/module.xml @@ -0,0 +1,12 @@ + + + + + deptree-extra + 1.0.0 + module + src + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-extra/src/cls/DepTree/Extra.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-extra/src/cls/DepTree/Extra.cls new file mode 100644 index 000000000..d64250737 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-extra/src/cls/DepTree/Extra.cls @@ -0,0 +1,9 @@ +Class DepTree.Extra +{ + +ClassMethod GetName() As %String +{ + return "deptree-extra" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-leaf/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-leaf/module.xml new file mode 100644 index 000000000..508ac6f19 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-leaf/module.xml @@ -0,0 +1,12 @@ + + + + + deptree-leaf + 1.0.0 + module + src + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-leaf/src/cls/DepTree/Leaf.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-leaf/src/cls/DepTree/Leaf.cls new file mode 100644 index 000000000..b6b7b6302 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-leaf/src/cls/DepTree/Leaf.cls @@ -0,0 +1,9 @@ +Class DepTree.Leaf +{ + +ClassMethod GetName() As %String +{ + return "deptree-leaf" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v1/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v1/module.xml new file mode 100644 index 000000000..b87aa3bcb --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v1/module.xml @@ -0,0 +1,22 @@ + + + + + deptree-parent + 1.0.0 + module + src + + + deptree-dropped + 1.0.0 + + + deptree-stable + 1.0.0 + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v1/src/cls/DepTree/Parent.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v1/src/cls/DepTree/Parent.cls new file mode 100644 index 000000000..6e9f5264e --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v1/src/cls/DepTree/Parent.cls @@ -0,0 +1,9 @@ +Class DepTree.Parent +{ + +ClassMethod GetName() As %String +{ + return "deptree-parent" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v2/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v2/module.xml new file mode 100644 index 000000000..3c5029c94 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v2/module.xml @@ -0,0 +1,22 @@ + + + + + deptree-parent + 2.0.0 + module + src + + + deptree-added + 1.0.0 + + + deptree-stable + 2.0.0 + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v2/src/cls/DepTree/Parent.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v2/src/cls/DepTree/Parent.cls new file mode 100644 index 000000000..6e9f5264e --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-parent-v2/src/cls/DepTree/Parent.cls @@ -0,0 +1,9 @@ +Class DepTree.Parent +{ + +ClassMethod GetName() As %String +{ + return "deptree-parent" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/module.xml new file mode 100644 index 000000000..4d525fed3 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/module.xml @@ -0,0 +1,14 @@ + + + + + deptree-stable + 1.0.0 + module + src + + + DepTree.StableSteps + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/src/cls/DepTree/Stable.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/src/cls/DepTree/Stable.cls new file mode 100644 index 000000000..d77e718f4 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/src/cls/DepTree/Stable.cls @@ -0,0 +1,9 @@ +Class DepTree.Stable +{ + +ClassMethod GetName() As %String +{ + return "deptree-stable" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/src/cls/DepTree/StableSteps/V1.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/src/cls/DepTree/StableSteps/V1.cls new file mode 100644 index 000000000..b0bb38958 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v1/src/cls/DepTree/StableSteps/V1.cls @@ -0,0 +1,20 @@ +Class DepTree.StableSteps.V1 Extends %IPM.General.Update.VersionBase +{ + +ClassMethod GetOrderedMethods() As %Library.DynamicArray +{ + return ["Step001"] +} + +ClassMethod GetOrderedMethodsPrimaryOnly() As %Library.DynamicArray +{ + return [] +} + +/// Non-idempotent: increments a counter global so tests can detect if this ran. +ClassMethod Step001() +{ + set ^DBPkgDepTreeSteps("step001") = $get(^DBPkgDepTreeSteps("step001"), 0) + 1 +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/module.xml new file mode 100644 index 000000000..a12e51a4e --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/module.xml @@ -0,0 +1,20 @@ + + + + + deptree-stable + 2.0.0 + module + src + + + deptree-extra + 1.0.0 + + + + + DepTree.StableSteps + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/Stable.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/Stable.cls new file mode 100644 index 000000000..d77e718f4 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/Stable.cls @@ -0,0 +1,9 @@ +Class DepTree.Stable +{ + +ClassMethod GetName() As %String +{ + return "deptree-stable" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/StableSteps/V1.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/StableSteps/V1.cls new file mode 100644 index 000000000..b0bb38958 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/StableSteps/V1.cls @@ -0,0 +1,20 @@ +Class DepTree.StableSteps.V1 Extends %IPM.General.Update.VersionBase +{ + +ClassMethod GetOrderedMethods() As %Library.DynamicArray +{ + return ["Step001"] +} + +ClassMethod GetOrderedMethodsPrimaryOnly() As %Library.DynamicArray +{ + return [] +} + +/// Non-idempotent: increments a counter global so tests can detect if this ran. +ClassMethod Step001() +{ + set ^DBPkgDepTreeSteps("step001") = $get(^DBPkgDepTreeSteps("step001"), 0) + 1 +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/StableSteps/V2.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/StableSteps/V2.cls new file mode 100644 index 000000000..8e3d26203 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/deptree-stable-v2/src/cls/DepTree/StableSteps/V2.cls @@ -0,0 +1,20 @@ +Class DepTree.StableSteps.V2 Extends %IPM.General.Update.VersionBase +{ + +ClassMethod GetOrderedMethods() As %Library.DynamicArray +{ + return ["Step002"] +} + +ClassMethod GetOrderedMethodsPrimaryOnly() As %Library.DynamicArray +{ + return [] +} + +/// Non-idempotent: increments a counter global so tests can detect if this ran. +ClassMethod Step002() +{ + set ^DBPkgDepTreeSteps("step002") = $get(^DBPkgDepTreeSteps("step002"), 0) + 1 +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/main-data.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/main-data.txt new file mode 100644 index 000000000..1f95f2cfe --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/main-data.txt @@ -0,0 +1 @@ +root file copy payload diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/module.xml new file mode 100644 index 000000000..fd295066a --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/module.xml @@ -0,0 +1,20 @@ + + + + + main-with-resource-deps + 1.0.0 + module + src + + + dep-with-resources + 1.0.0 + + + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/src/cls/MainRes/App.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/src/cls/MainRes/App.cls new file mode 100644 index 000000000..e65563673 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/src/cls/MainRes/App.cls @@ -0,0 +1,10 @@ +Class MainRes.App +{ + +/// Proves the dependency's compiled code is in the same IRIS.DAT. +ClassMethod UseDependency() As %String +{ + return "main uses " _ ##class(DepRes.Core).GetVersion() +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/src/cpf/main.cpf b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/src/cpf/main.cpf new file mode 100644 index 000000000..3bba7d7cb --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/main-with-resource-deps/src/cpf/main.cpf @@ -0,0 +1,2 @@ +[config] +globals=0,0,170000,0,0,0 diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/csp/allresources/index.csp b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/csp/allresources/index.csp new file mode 100644 index 000000000..068fc9e61 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/csp/allresources/index.csp @@ -0,0 +1,6 @@ + + +

All Resources Module CSP Page

+

This is a test CSP file for database packaging.

+ + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/csp/allresources/style.css b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/csp/allresources/style.css new file mode 100644 index 000000000..50b1c984c --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/csp/allresources/style.css @@ -0,0 +1,4 @@ +body { + font-family: Arial, sans-serif; + background-color: #f0f0f0; +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/module.xml new file mode 100644 index 000000000..6624ee0d4 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/module.xml @@ -0,0 +1,28 @@ + + + + + all-resources-module + 1.0.0 + module + src + + + + + + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/cls/AllResources/Generator.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/cls/AllResources/Generator.cls new file mode 100644 index 000000000..bdd09c804 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/cls/AllResources/Generator.cls @@ -0,0 +1,35 @@ +Class AllResources.Generator +{ + +/// Generates the AllResources.Generated class using %Dictionary API and compiles it. +/// Called via After Compile so the generator itself is already compiled. +ClassMethod Generate() As %Status +{ + set sc = $$$OK + try { + // Remove existing definition if present (idempotent) + if ##class(%Dictionary.ClassDefinition).%ExistsId("AllResources.Generated") { + $$$ThrowOnError(##class(%Dictionary.ClassDefinition).%DeleteId("AllResources.Generated")) + } + + set cls = ##class(%Dictionary.ClassDefinition).%New() + set cls.Name = "AllResources.Generated" + + set meth = ##class(%Dictionary.MethodDefinition).%New() + set meth.Name = "GetGeneratedValue" + set meth.ClassMethod = 1 + set meth.ReturnType = "%String" + set impl = ##class(%Stream.TmpCharacter).%New() + do impl.Write(" return ""Generated resource value""") + set meth.Implementation = impl + do cls.Methods.Insert(meth) + + $$$ThrowOnError(cls.%Save()) + $$$ThrowOnError($system.OBJ.Compile("AllResources.Generated", "ck-d")) + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/cls/AllResources/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/cls/AllResources/Main.cls new file mode 100644 index 000000000..be06a4e1b --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/cls/AllResources/Main.cls @@ -0,0 +1,10 @@ +Class AllResources.Main +{ + +ClassMethod Process() As %String +{ + #include AllResources + return "Processing with constant: " _ $$$CONSTANT +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/inc/AllResources.INC b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/inc/AllResources.INC new file mode 100644 index 000000000..6b48f0797 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/src/inc/AllResources.INC @@ -0,0 +1,3 @@ +ROUTINE AllResources [Type=INC] + +#define CONSTANT "TestValue" diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/test-data.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/test-data.txt new file mode 100644 index 000000000..5fcd7c906 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/test-data.txt @@ -0,0 +1 @@ +This is test data for FileCopy resource diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/wheels/colorama-0.4.6-py2.py3-none-any.whl b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/wheels/colorama-0.4.6-py2.py3-none-any.whl new file mode 100644 index 000000000..f666ce989 Binary files /dev/null and b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-all-resources/wheels/colorama-0.4.6-py2.py3-none-any.whl differ diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-deps/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-deps/module.xml new file mode 100644 index 000000000..3258e09ce --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-deps/module.xml @@ -0,0 +1,18 @@ + + + + + main-with-deps + 1.0.0 + module + src + + + dep-module + 1.0.0 + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-deps/src/cls/MainModule/App.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-deps/src/cls/MainModule/App.cls new file mode 100644 index 000000000..87508a50b --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-deps/src/cls/MainModule/App.cls @@ -0,0 +1,9 @@ +Class MainModule.App +{ + +ClassMethod UseDependency() As %String +{ + return "Using dependency version: " _ ##class(DepModule.Core).GetVersion() +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/module.xml new file mode 100644 index 000000000..b16d2e6d5 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/module.xml @@ -0,0 +1,15 @@ + + + + + module-with-invokes + 1.0.0 + module + src + + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/src/cls/InvokeModule/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/src/cls/InvokeModule/Main.cls new file mode 100644 index 000000000..4e0b75d7a --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/src/cls/InvokeModule/Main.cls @@ -0,0 +1,9 @@ +Class InvokeModule.Main +{ + +ClassMethod Hello() As %String +{ + quit "Hello from InvokeModule" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/src/cls/InvokeModule/Setup.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/src/cls/InvokeModule/Setup.cls new file mode 100644 index 000000000..f46941f03 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-invokes/src/cls/InvokeModule/Setup.cls @@ -0,0 +1,20 @@ +Class InvokeModule.Setup +{ + +/// Called after Compile phase. Sets a global marker to prove it ran. +/// During database install this should run — the DB is mounted after %Reload completes. +ClassMethod OnCompile() As %Status +{ + set ^InvokeTest("compile") = 1 + quit $$$OK +} + +/// Called after Activate phase. Sets a global marker to prove it ran. +/// During database install this should run. +ClassMethod OnActivate() As %Status +{ + set ^InvokeTest("activate") = 1 + quit $$$OK +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/module.xml new file mode 100644 index 000000000..50bb18ff5 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/module.xml @@ -0,0 +1,13 @@ + + + + + module-with-mixed-python + 1.0.0 + module + src + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/requirements.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/requirements.txt new file mode 100644 index 000000000..b1b315b30 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/requirements.txt @@ -0,0 +1 @@ +pycparser==2.22 diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/src/cls/MixedPyModule/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/src/cls/MixedPyModule/Main.cls new file mode 100644 index 000000000..c0e41fe89 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/src/cls/MixedPyModule/Main.cls @@ -0,0 +1,9 @@ +Class MixedPyModule.Main +{ + +ClassMethod Hello() As %String +{ + quit "Hello from MixedPyModule" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/wheels/lune-1.6.2-py3-none-any.whl b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/wheels/lune-1.6.2-py3-none-any.whl new file mode 100644 index 000000000..e1c9f0270 Binary files /dev/null and b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-mixed-python/wheels/lune-1.6.2-py3-none-any.whl differ diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/module.xml new file mode 100644 index 000000000..cdbaa5d54 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/module.xml @@ -0,0 +1,14 @@ + + + + + module-with-non-compiled-resources + 1.0.0 + module + src + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/non-compiled-data.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/non-compiled-data.txt new file mode 100644 index 000000000..1f501ac09 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/non-compiled-data.txt @@ -0,0 +1 @@ +Non-compiled resource test data diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/src/cls/NonCompiled/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/src/cls/NonCompiled/Main.cls new file mode 100644 index 000000000..ec0a640de --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/src/cls/NonCompiled/Main.cls @@ -0,0 +1,9 @@ +Class NonCompiled.Main +{ + +ClassMethod GetMessage() As %String +{ + return "Non-compiled resources module" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/src/cpf/config.cpf b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/src/cpf/config.cpf new file mode 100644 index 000000000..582c4fe09 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-non-compiled-resources/src/cpf/config.cpf @@ -0,0 +1,2 @@ +[config] +globals=0,0,150000,0,0,0 diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/module.xml new file mode 100644 index 000000000..a26cdc6f4 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/module.xml @@ -0,0 +1,12 @@ + + + + + module-with-requirements + 1.0.0 + module + src + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/requirements.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/requirements.txt new file mode 100644 index 000000000..a84363397 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/requirements.txt @@ -0,0 +1 @@ +packaging==25.0 diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/src/cls/ReqModule/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/src/cls/ReqModule/Main.cls new file mode 100644 index 000000000..a624f599c --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-requirements/src/cls/ReqModule/Main.cls @@ -0,0 +1,9 @@ +Class ReqModule.Main +{ + +ClassMethod Hello() As %String +{ + quit "Hello from ReqModule" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/module.xml new file mode 100644 index 000000000..06111b365 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/module.xml @@ -0,0 +1,14 @@ + + + + + module-with-tests + 1.0.0 + module + src + + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/src/cls/TestModule/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/src/cls/TestModule/Main.cls new file mode 100644 index 000000000..6669902e7 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/src/cls/TestModule/Main.cls @@ -0,0 +1,9 @@ +Class TestModule.Main +{ + +ClassMethod DoWork() As %String +{ + return "Production code" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/src/cls/TestModule/TestHelper.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/src/cls/TestModule/TestHelper.cls new file mode 100644 index 000000000..b4e966115 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/src/cls/TestModule/TestHelper.cls @@ -0,0 +1,9 @@ +Class TestModule.TestHelper +{ + +ClassMethod GetHelperValue() As %String +{ + return "helper value" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/tests/Test.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/tests/Test.cls new file mode 100644 index 000000000..5dcad0a63 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-tests/tests/Test.cls @@ -0,0 +1,10 @@ +Class TestModule.Test Extends %UnitTest.TestCase +{ + +Method TestDoWork() +{ + set result = ##class(TestModule.Main).DoWork() + do $$$AssertEquals(result, "Production code") +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/module.xml new file mode 100644 index 000000000..7012585b3 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/module.xml @@ -0,0 +1,13 @@ + + + + + update-steps-module + 1.0.0 + module + src + + UpdateStepsModule.Steps + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/src/cls/UpdateStepsModule/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/src/cls/UpdateStepsModule/Main.cls new file mode 100644 index 000000000..d450d33c2 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/src/cls/UpdateStepsModule/Main.cls @@ -0,0 +1,9 @@ +Class UpdateStepsModule.Main +{ + +ClassMethod GetVersion() As %String +{ + return "1.0.0" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/src/cls/UpdateStepsModule/Steps/V1.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/src/cls/UpdateStepsModule/Steps/V1.cls new file mode 100644 index 000000000..44fd5216e --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v1/src/cls/UpdateStepsModule/Steps/V1.cls @@ -0,0 +1,20 @@ +Class UpdateStepsModule.Steps.V1 Extends %IPM.General.Update.VersionBase +{ + +ClassMethod GetOrderedMethods() As %Library.DynamicArray +{ + return ["Step001"] +} + +ClassMethod GetOrderedMethodsPrimaryOnly() As %Library.DynamicArray +{ + return [] +} + +/// Non-idempotent: increments a counter global so tests can detect if this ran. +ClassMethod Step001() +{ + set ^DBPkgUpdateSteps("step001") = $get(^DBPkgUpdateSteps("step001"), 0) + 1 +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/module.xml new file mode 100644 index 000000000..82eb20928 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/module.xml @@ -0,0 +1,13 @@ + + + + + update-steps-module + 2.0.0 + module + src + + UpdateStepsModule.Steps + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Main.cls new file mode 100644 index 000000000..2e1c72510 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Main.cls @@ -0,0 +1,9 @@ +Class UpdateStepsModule.Main +{ + +ClassMethod GetVersion() As %String +{ + return "2.0.0" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Steps/V1.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Steps/V1.cls new file mode 100644 index 000000000..44fd5216e --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Steps/V1.cls @@ -0,0 +1,20 @@ +Class UpdateStepsModule.Steps.V1 Extends %IPM.General.Update.VersionBase +{ + +ClassMethod GetOrderedMethods() As %Library.DynamicArray +{ + return ["Step001"] +} + +ClassMethod GetOrderedMethodsPrimaryOnly() As %Library.DynamicArray +{ + return [] +} + +/// Non-idempotent: increments a counter global so tests can detect if this ran. +ClassMethod Step001() +{ + set ^DBPkgUpdateSteps("step001") = $get(^DBPkgUpdateSteps("step001"), 0) + 1 +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Steps/V2.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Steps/V2.cls new file mode 100644 index 000000000..93bfc955f --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-with-update-steps-v2/src/cls/UpdateStepsModule/Steps/V2.cls @@ -0,0 +1,20 @@ +Class UpdateStepsModule.Steps.V2 Extends %IPM.General.Update.VersionBase +{ + +ClassMethod GetOrderedMethods() As %Library.DynamicArray +{ + return ["Step002"] +} + +ClassMethod GetOrderedMethodsPrimaryOnly() As %Library.DynamicArray +{ + return [] +} + +/// Non-idempotent: increments a counter global so tests can detect if this ran. +ClassMethod Step002() +{ + set ^DBPkgUpdateSteps("step002") = $get(^DBPkgUpdateSteps("step002"), 0) + 1 +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-without-code/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-without-code/module.xml new file mode 100644 index 000000000..ae8fddd65 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-without-code/module.xml @@ -0,0 +1,12 @@ + + + + + module-without-code + 1.0.0 + module + src + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-without-code/no-code-data.txt b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-without-code/no-code-data.txt new file mode 100644 index 000000000..3cebcbd9e --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/module-without-code/no-code-data.txt @@ -0,0 +1,2 @@ +Data file for module-without-code. This module has no code resources, so a database +swap in its namespace cannot orphan it. diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/module.xml b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/module.xml new file mode 100644 index 000000000..5178d3dc6 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/module.xml @@ -0,0 +1,13 @@ + + + + + simple-db-module + 1.0.0 + module + src + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/src/cls/SimpleModule/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/src/cls/SimpleModule/Main.cls new file mode 100644 index 000000000..6f3dd7881 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/src/cls/SimpleModule/Main.cls @@ -0,0 +1,16 @@ +Class SimpleModule.Main +{ + +ClassMethod GetMessage() As %String +{ + return "Hello from Simple DB Module" +} + +ClassMethod Add( + a As %Integer, + b As %Integer) As %Integer +{ + return a + b +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/src/cls/SimpleModule/Utils.cls b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/src/cls/SimpleModule/Utils.cls new file mode 100644 index 000000000..462e21bd6 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/db-packaging/simple-module/src/cls/SimpleModule/Utils.cls @@ -0,0 +1,9 @@ +Class SimpleModule.Utils +{ + +ClassMethod FormatString(input As %String) As %String +{ + return "Formatted: " _ input +} + +} diff --git a/tests/unit_tests/Test/PM/Unit/LifecycleDatabase.cls b/tests/unit_tests/Test/PM/Unit/LifecycleDatabase.cls new file mode 100644 index 000000000..648eaa989 --- /dev/null +++ b/tests/unit_tests/Test/PM/Unit/LifecycleDatabase.cls @@ -0,0 +1,178 @@ +Class Test.PM.Unit.LifecycleDatabase Extends %UnitTest.TestCase +{ + +/// Modules created outside of this test save modules to storage under fixed names; delete them +/// unconditionally so a failed assert cannot leave rows behind for the next run. +Method OnAfterOneTest(testName As %String) As %Status +{ + for name = "aa-root", "mm-mid", "zz-base" { + if ##class(%IPM.Storage.Module).NameExists(name) { + do ##class(%IPM.Storage.Module).NameDelete(name) + } + } + quit $$$OK +} + +/// A module declaring Packaging="database" must resolve to %IPM.Lifecycle.Database, not the generic +/// %IPM.Lifecycle.Module. Covers both derivation branches in %OnValidateObject: the default +/// LifecycleClass being switched out, and an empty LifecycleClass being filled in. +Method TestPackagingDerivesLifecycleClass() +{ + set module = ##class(%IPM.Storage.Module).%New() + set module.Name = "derive-lifecycle-check" + set module.VersionString = "1.0.0" + set module.Packaging = "database" + + // LifecycleClass has an InitialExpression of %IPM.Lifecycle.Module, so this leaves the default + // in place and validation must notice it disagrees with Packaging. + do $$$AssertStatusOK(module.%ValidateObject(), "Module with default LifecycleClass and Packaging=database validates") + do $$$AssertEquals(module.LifecycleClass, "%IPM.Lifecycle.Database", "Default LifecycleClass replaced from database packaging") + + // The other branch: nothing declared at all, so the class is derived rather than replaced. + set module.LifecycleClass = "" + do $$$AssertStatusOK(module.%ValidateObject(), "Module with empty LifecycleClass and Packaging=database validates") + do $$$AssertEquals(module.LifecycleClass, "%IPM.Lifecycle.Database", "Empty LifecycleClass derived from database packaging") +} + +/// IsPackagedManifest decides whether a directory is the remains of an already-swapped install, +/// which %Reload refuses to load as source. Detection is the element, so a source +/// manifest and a packaged one must be told apart on that alone. +Method TestIsPackagedManifestDetection() +{ + // TempFilename creates the file it names, so derive a sibling directory from it and delete it. + set tempFile = ##class(%Library.File).TempFilename() + set dir = ##class(%Library.File).NormalizeDirectory(tempFile _ ".d") + do ##class(%Library.File).Delete(tempFile) + do $$$AssertTrue(##class(%Library.File).CreateDirectoryChain(dir), "Created temp directory") + + do $$$AssertNotTrue(##class(%IPM.Lifecycle.Database).IsPackagedManifest(dir), "Directory with no module.xml is not a packaged manifest") + do $$$AssertNotTrue(##class(%IPM.Lifecycle.Database).IsPackagedManifest(""), "Empty directory path is not a packaged manifest") + + set header = "" _ $char(13, 10) _ "" _ $char(13, 10) _ + "pkg-check1.0.0" + + // A source manifest: same shape, no Checksum. + do ..WriteModuleXML(dir, header _ "") + do $$$AssertNotTrue(##class(%IPM.Lifecycle.Database).IsPackagedManifest(dir), "Source manifest is not a packaged manifest") + + // Not valid XML at all: reported by the source load path, not here. + do ..WriteModuleXML(dir, "database
" _ + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" _ + "") + do $$$AssertTrue(##class(%IPM.Lifecycle.Database).IsPackagedManifest(dir), "Manifest with Checksum is a packaged manifest") + + do ##class(%Library.File).RemoveDirectoryTree(dir) +} + +/// Writes content to dir/module.xml, replacing any previous file. +Method WriteModuleXML( + dir As %String, + content As %String) +{ + set path = ##class(%Library.File).NormalizeFilename("module.xml", dir) + // Delete rather than Clear: this is called repeatedly against the same path, and a fresh file + // is unambiguous about what the stream ends up containing. + do ##class(%Library.File).Delete(path) + set stream = ##class(%Stream.FileCharacter).%New() + do $$$AssertStatusOK(stream.LinkToFile(path), "Linked module.xml") + do $$$AssertStatusOK(stream.Write(content), "Wrote module.xml content") + do $$$AssertStatusOK(stream.%Save(), "Saved module.xml") +} + +/// Guards the ComputeSHA256Hex output contract (64-char lowercase hex): the hash is produced +/// via the %xsd.hexBinary encoder, so this pins the length and lowercasing. +Method TestSHA256HexFormat() +{ + // Write a small known file and hash it. + set tmp = ##class(%Library.File).TempFilename("bin") + set stream = ##class(%Stream.FileBinary).%New() + do $$$AssertStatusOK(stream.LinkToFile(tmp), "Linked stream to temp file") + do $$$AssertStatusOK(stream.Write("abc"), "Wrote test content") + do $$$AssertStatusOK(stream.%Save(), "Saved temp file") + set hex = ##class(%IPM.Lifecycle.Database).ComputeSHA256Hex(tmp) + do ##class(%Library.File).Delete(tmp) + + do $$$AssertEquals($length(hex), 64, "SHA-256 hex is 64 characters") + do $$$AssertEquals($zconvert(hex, "L"), hex, "SHA-256 hex is lowercase") + // SHA-256 of "abc" is a well-known constant. + do $$$AssertEquals(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "SHA-256('abc') matches known value") +} + +/// GetTopologicalDependencyNames must place a dependency before its dependent, unlike +/// GetDependencyNames which returns name order. Uses in-memory module objects saved to storage +/// under names that sort against the required order, so name order and topological order differ. +Method TestTopologicalDependencyOrder() +{ + // "zz-base" <- "mm-mid" <- root. Name order would give mm-mid, zz-base; topological order + // must give zz-base, mm-mid. + set base = ##class(%IPM.Storage.Module).%New() + set base.Name = "zz-base" + set base.VersionString = "1.0.0" + do $$$AssertStatusOK(base.%Save(), "Saved zz-base") + + set mid = ##class(%IPM.Storage.Module).%New() + set mid.Name = "mm-mid" + set mid.VersionString = "1.0.0" + set midDep = ##class(%IPM.Storage.ModuleReference).%New() + set midDep.Name = "zz-base" + set midDep.VersionString = "1.0.0" + do mid.Dependencies.Insert(midDep) + do $$$AssertStatusOK(mid.%Save(), "Saved mm-mid") + + set root = ##class(%IPM.Storage.Module).%New() + set root.Name = "aa-root" + set root.VersionString = "1.0.0" + set rootDep = ##class(%IPM.Storage.ModuleReference).%New() + set rootDep.Name = "mm-mid" + set rootDep.VersionString = "1.0.0" + do root.Dependencies.Insert(rootDep) + do $$$AssertStatusOK(root.%Save(), "Saved aa-root") + + set ordered = ##class(%IPM.Lifecycle.Database).GetTopologicalDependencyNames(root) + do $$$AssertEquals($listtostring(ordered, ","), "zz-base,mm-mid", "Dependency precedes dependent") +} + +/// Every phase in the canonical install chain must be either run for dependencies or explicitly +/// excluded. Fails when a phase is added to Base.GetCompletePhasesForOne without that decision +/// being made — the exclusion list in Database.GetDependencyExcludedPhases is where to record it. +Method TestDependencyPhasesCoverCanonicalChain() +{ + for entryPhase = "Activate", "ApplyUpdateSteps" { + set isUpdate = (entryPhase = "ApplyUpdateSteps") + set canonical = ##class(%IPM.Lifecycle.Base).GetCompletePhasesForOne(entryPhase) + set run = ##class(%IPM.Lifecycle.Database).GetDependencyInstallPhases(isUpdate) + set excluded = ##class(%IPM.Lifecycle.Database).GetDependencyExcludedPhases() + + set ptr = 0 + while $listnext(canonical, ptr, onePhase) { + continue:onePhase="" + set accountedFor = ($listfind(run, onePhase) > 0) || ($listfind(excluded, onePhase) > 0) + do $$$AssertTrue(accountedFor, "Canonical phase '" _ onePhase _ "' (" _ entryPhase _ " chain) is either run for dependencies or explicitly excluded") + } + + // A phase cannot be both, and nothing may be run that the canonical chain does not contain. + set runPtr = 0 + while $listnext(run, runPtr, runPhase) { + do $$$AssertNotTrue($listfind(excluded, runPhase), "Phase '" _ runPhase _ "' is not both run and excluded") + do $$$AssertTrue($listfind(canonical, runPhase) > 0, "Phase '" _ runPhase _ "' comes from the canonical chain") + } + } +} + +/// Pins the exclusions actually taking effect, so removing one is a visible test change rather +/// than a silent behavior change. +Method TestDependencyPhasesExcludeReloadAndCompile() +{ + set phases = ##class(%IPM.Lifecycle.Database).GetDependencyInstallPhases(0) + do $$$AssertEquals($listtostring(phases, ","), "Initialize,Validate,Activate", "Dependency install phases") + + // An update runs each dependency's own update steps, matching a source update: params("Update") + // propagates to dependency loads, which then enter at ApplyUpdateSteps. + set updatePhases = ##class(%IPM.Lifecycle.Database).GetDependencyInstallPhases(1) + do $$$AssertEquals($listtostring(updatePhases, ","), "Initialize,Validate,Activate,ApplyUpdateSteps", "Dependency update phases") +} + +}