diff --git a/cfbs.json b/cfbs.json index 555f71a..e1f1249 100644 --- a/cfbs.json +++ b/cfbs.json @@ -154,6 +154,52 @@ "bundles maintainers_in_motd" ] }, + "powershell-execution-policy": { + "description": "Inventory and bundle for PowerShell Execution Policy", + "subdirectory": "management/powershell-execution-policy", + "steps": [ + "directory ./ services/cfbs/powershell-execution-policy/", + "policy_files services/cfbs/powershell-execution-policy/", + "bundles powershell_execution_policy_inventory" + ] + }, + "package-method-winget": { + "description": "Package method for Windows winget package manager.", + "subdirectory": "management/package-method-winget", + "dependencies": ["powershell-execution-policy"], + "steps": [ + "input ./input.json def.json", + "directory ./ services/cfbs/modules/package-method-winget/", + "policy_files services/cfbs/modules/package-method-winget/", + "bundles package_method_winget:package_method_winget winget_installed:winget_installed" + ], + "input": [ + { + "type": "string", + "variable": "accept_source_agreements", + "namespace": "data", + "bundle": "package_method_winget", + "label": "Accept Source Agreements", + "question": "Would you like to accept source agreements for winget packages promises? [yes|no]" + }, + { + "type": "string", + "variable": "accept_package_agreements", + "namespace": "data", + "bundle": "package_method_winget", + "label": "Accept Package Agreements", + "question": "Would you like to accept package agreements for winget packages promises? [yes|no]" + }, + { + "type": "string", + "variable": "allow_powershell_execution_policy_change", + "namespace": "data", + "bundle": "winget_installed", + "label": "Allow necessary PowerShell Execution Policy change: LocalMachine set to Unrestricted in order to install winget and cmdlets", + "question": "Would you like to allow this module to change PowerShell Execution Policy to LocalMachine:Unrestricted? [yes|no]" + } + ] + }, "promise-type-ansible": { "description": "Promise type to manage systemd services.", "subdirectory": "promise-types/ansible", diff --git a/management/package-method-winget/README.md b/management/package-method-winget/README.md new file mode 100644 index 0000000..c9fd99e --- /dev/null +++ b/management/package-method-winget/README.md @@ -0,0 +1,52 @@ +# package-method-winget + +## Usage +This module enables a `winget` package method used with policy like: + +```cf3 +packages: + windows:: + "Microsoft.WindowsTerminal" + package_method => winget, + package_policy => "add"; +``` + +## Opt-in for accepting source and package agreements (Required) + +In order for winget to operate properly you must opt-in to accepting source and packaging agreements. +Without this acceptance the package method will fail with a message like: + +```console + info: Installing Microsoft.WindowsTerminal... + info: Q:powershell.exe -Comm ...:You must set some vars for package-method-winget to work + error: Finished command related to promiser 'Microsoft.WindowsTerminal' -- an error occurred, returned 1 + error: Bulk package schedule execution failed somewhere - unknown outcome for 'Microsoft.WindowsTerminal' +``` + +Acceptance can be given either via cfbs inputs, augments, group or host specific data. + +Set the value of `yes` in the following variables: +- `data:package_method_winget.accept_source_agreements` +- `data:package_method_winget.accept_package_agreements` + +## Installation +This module uses both the `winget` command as well as the `Microsoft.WinGet.Client` PowerShell module as that makes it easier to gather the list of currently installed packages. + +`winget` should be installed on most newer desktop systems by default. +Server images often do not have `winget` installed. + +In order for `winget` and `Microsoft.WinGet.Client` to be installed, ps1 scripts must be run which requires `PowerShell Execution policy` `Unrestricted` for `LocalMachine`. +The policy by default will not make this change. +You must opt-in by setting the variable `winget_installed.allow_powershell_execution_policy_change` to the value of `yes`. +This can be set in Host/Group data or via Augments in the `data` namespace. + +```json +{ + "variables": { + "data:winget_installed.allow_powershell_execution_policy_change": { + "value": "yes" + } + } +} +``` + diff --git a/management/package-method-winget/install-winget-cli.ps1 b/management/package-method-winget/install-winget-cli.ps1 new file mode 100644 index 0000000..3e002c6 --- /dev/null +++ b/management/package-method-winget/install-winget-cli.ps1 @@ -0,0 +1,6 @@ +# instructions from https://github.com/microsoft/winget-cli +# WinGet.Client needs NuGet +Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force +Set-PSRepository -Name PSGallery -InstallationPolicy Trusted +Install-Module -Name Microsoft.WinGet.Client +Import-Module -Name Microsoft.WinGet.Client diff --git a/management/package-method-winget/install-winget.ps1 b/management/package-method-winget/install-winget.ps1 new file mode 100644 index 0000000..f7e7a59 --- /dev/null +++ b/management/package-method-winget/install-winget.ps1 @@ -0,0 +1,11 @@ +# from https://learn.microsoft.com/en-us/windows/package-manager/winget/#install-winget +# TODO, find a way to install the "latest" instead of these hard-coded versions +# as-is if newer versions are already installed this script should still succeed and serve it's purpose +$progressPreference = 'silentlyContinue' +Write-Information "Downloading WinGet and its dependencies..." +Invoke-WebRequest -Uri https://aka.ms/getwinget -OutFile Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle +Invoke-WebRequest -Uri https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx -OutFile Microsoft.VCLibs.x64.14.00.Desktop.appx +Invoke-WebRequest -Uri https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.x64.appx -OutFile Microsoft.UI.Xaml.2.8.x64.appx +Add-AppxPackage Microsoft.VCLibs.x64.14.00.Desktop.appx +Add-AppxPackage Microsoft.UI.Xaml.2.8.x64.appx +Add-AppxPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle diff --git a/management/package-method-winget/package-method-winget.cf b/management/package-method-winget/package-method-winget.cf new file mode 100644 index 0000000..8c3db90 --- /dev/null +++ b/management/package-method-winget/package-method-winget.cf @@ -0,0 +1,55 @@ +# NOTE: CASE MATTERS in package names, e.g. Docker.DockerDesktop will match but docker.dockerdesktop won't and so will re-install each agent run! +# TODO: fix case sensitive package names? ^^^ +body package_method winget +{ + package_changes => "bulk"; + package_name_convention => "$(name)"; + package_delete_convention => "$(name)"; + + package_installed_regex => ".*"; + # Note that package_list_name_regex does not allow for commas inside of package names as parsing that from ConvertTo-Csv below would be too complex + # an example of output of the package_list_command below is + # + # "Microsoft.WindowsTerminal","1.21.2361.0" + # + # so the matches below simply grab the thing before or after the comma separating Id and InstalledVersion fields. + package_list_name_regex => '^"([^"]*)",.*'; + package_list_version_regex => '.*,"([^"]*)".*'; + + + # Here we use the Get-WinGetPackage Cmdlet because it is easier to produce easily parsed information that way + package_list_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"Get-WinGetPackage | Select Id,InstalledVersion | ConvertTo-Csv "; + package_delete_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"Uninstall-WinGetPackage "; + + # Here we use winget instead of PowerShell Cmdlets because we can provide the --accept-source-agreements and --accept-package-agreements this way which gets around dialog prompts + package_method_winget:accept_source_agreements.package_method_winget:accept_package_agreements:: + package_add_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"winget install --accept-source-agreements --accept-package-agreements "; + !package_method_winget:accept_source_agreements|!package_method_winget:accept_package_agreements:: + # the package name is appended to the end of this command, so we try here to make a command which conveys information only + package_add_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"Write-Host You must set some vars for package-method-winget to work;exit 1; rem Trying to add package:"; +} + +# switch to module specific namespace to avoid name collisions +body file control +{ + namespace => "package_method_winget"; +} + +# package_method_winget bundle's purpose is to look at inputs/data for acceptance of source and package agreements +# these MUST be agreed to in order for this package method to work properly. +bundle agent package_method_winget +{ + classes: + "accept_source_agreements" expression => regcmp("^[yY][eE]?[sS]?", "${data:package_method_winget.accept_source_agreements}"), + scope => "namespace"; + "accept_package_agreements" expression => regcmp("^[yY][eE]?[sS]?", "${data:package_method_winget.accept_package_agreements}"), + scope => "namespace"; + + reports: + windows:: + "Please specify if you wish to --accept-source-agreements when using winget package method promises with the cfbs inputs or the variable data:package_method_winget.accept_source_agreements having the value yes or no" + if => "!accept_source_agreements"; + "Please specify if you wish to --accept-package-agreements when using winget package method promises with cfbs inputs or the variable data:package_method_winget.accept_package_agreements having the value yes or no" + if => "!accept_package_agreements"; +} + diff --git a/management/package-method-winget/winget-installed.cf b/management/package-method-winget/winget-installed.cf new file mode 100644 index 0000000..a14363e --- /dev/null +++ b/management/package-method-winget/winget-installed.cf @@ -0,0 +1,35 @@ +body file control +{ + namespace => "winget_installed"; +} + +bundle agent winget_installed +{ + classes: + windows:: + "winget_not_installed" expression => not(returnszero("winget -v | out-null", "powershell")); + "winget_cli_not_installed" expression => not(returnszero("Get-WinGetPackage | out-null", "powershell")); + "allow_powershell_execution_policy_change" expression => regcmp("^[yY][eE]?[sS]?", "${data:winget_installed.allow_powershell_execution_policy_change}"), + scope => "namespace"; + + commands: + windows.!winget_installed.execution_policy_ok:: + "powershell.exe -File '${this.promise_dirname}/install-winget.ps1'" + contain => default:powershell; + windows.!winget_cli_installed.execution_policy_ok:: + "powershell.exe -File '${this.promise_dirname}/install-winget-cli.ps1'" + contain => default:powershell; + + methods: + windows.(winget_not_installed|winget_cli_not_installed).allow_powershell_execution_policy_change:: + "powershell_execution_policy_set" usebundle => default:powershell_execution_policy_set("LocalMachine", "Unrestricted"), + classes => default:if_ok("execution_policy_ok"); + + reports: + windows.winget_not_installed:: + "In order for package-module-winget to function properly, winget must be installed."; + windows.winget_cli_not_installed:: + "In order for package-module-winget to function properly, winget-cli must be installed and imported."; + windows.(winget_not_installed|winget_cli_not_installed).!allow_powershell_execution_policy_change:: + "package-module-winget needs winget and/or winget-cli installed. Opt-in for this to be automated by this policy by setting the variable data:winget_installed.allow_powershell_execution_policy_change to 'yes'. This can be accomplished via cfbs inputs, group/host data or augments."; +} diff --git a/management/powershell-execution-policy/README.md b/management/powershell-execution-policy/README.md new file mode 100644 index 0000000..3fdd9e1 --- /dev/null +++ b/management/powershell-execution-policy/README.md @@ -0,0 +1,11 @@ +# powershell-execution-policy + +This module inventories and allows to set the state of the various `scopes` for PowerShell Execution Policy. + +See the [Set-ExecutionPolicy](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/set-executionpolicy?view=powershell-7.4) documentation for details about scope and state values. + +## Example + +```cf3 + "set_localmachine_unrestricted" usebundle => default:powershell_execution_policy_set("LocalMachine", "Unrestricted"); +``` diff --git a/management/powershell-execution-policy/powershell-execution-policy.cf b/management/powershell-execution-policy/powershell-execution-policy.cf new file mode 100644 index 0000000..4f65904 --- /dev/null +++ b/management/powershell-execution-policy/powershell-execution-policy.cf @@ -0,0 +1,32 @@ +# Technically this is PowerShell Execution Policy, not Windows specific (TODO: s/windows/powershell/) +# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.4 +bundle agent powershell_execution_policy_inventory +{ + vars: + windows:: + "execution_policy_csv_file" string => "${sys.statedir}${const.dirsep}powershell_execution_policy_list_cache.csv"; + "execution_policy_list_cache_command" string => "Get-ExecutionPolicy -list | ConvertTo-Csv -notypeinformation | select-object -skip 1 | Set-Content -Path '${execution_policy_csv_file}'"; + "csv" data => readcsv("${execution_policy_csv_file}"); + "i" slist => getindices("csv"); + "execution_policy_${csv[${i}][0]}" string => "${csv[${i}][0]}:${csv[${i}][1]}", + meta => { "inventory", "attribute_name=PowerShell Execution Policy" }; + + commands: + windows:: + "${execution_policy_list_cache_command}" + contain => powershell; +} + +# see link below for valid values for scope and policy +# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/set-executionpolicy?view=powershell-7.4 +# This bundle runs a powershell command: Set-ExecutionPolicy -ExecutionPolicy -Scope +bundle agent powershell_execution_policy_set(scope, desired_policy) +{ + classes: + "policy_not_ok" expression => returnszero("if((Get-ExecutionPolicy ${scope}) -ne '${desired_policy}'){exit 0}else{exit 1}", "powershell"); + + commands: + windows.policy_not_ok:: + "Set-ExecutionPolicy -ExecutionPolicy ${desired_policy} -Scope ${scope}" + contain => powershell; +}