From 2f9767bbfc2ea807a9eed44d7f2c2df047cac6f0 Mon Sep 17 00:00:00 2001 From: =?utf8?q?C=C3=A9sar=20Sagaert?= Date: Mon, 14 Jul 2025 14:14:03 +0200 Subject: [PATCH] feat: self-update command (#1963) * feat: function to get the latest alire release tag * fix: fail downloads on http errors * feat: self-update command * style: program box * fix: default to nightly update when 'dev' prerelease is detected * extract install step in procedure, various fixes * fix: better reporting * fix: abort without error when the latest version is already installed * check that release and artifact exist before downloading * feat: find running executable with the resources crate * small fixes * add success message * move tmp dir handling * very ugly windows hacks * fix CI * address most comments * minimum version for self-update feature * function to recreate alr global switches * use Non_Blocking_Spawn instead of `cmd /C start ...` in windows cleanup * group install error handling * clean up windows relaunch * use alire features for version check * logging improvements * move switches names to constants * some tests maybe * ... curl doesn't set com.apple.quarantine. That's only needed for browser downloads * test debugging * changelog * address comments --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci-release.yml | 3 +- .github/workflows/ci-toolchain.yml | 4 +- .github/workflows/ci-unsupported.yml | 3 +- .github/workflows/tarball-full.yml | 4 +- .gitmodules | 3 + alire.toml | 7 +- alr.gpr | 1 + alr_env.gpr | 1 + deps/resources | 1 + doc/user-changes.md | 17 + src/alire/alire-features.ads | 3 + src/alire/alire-github.adb | 59 ++ src/alire/alire-github.ads | 11 + src/alire/alire-os_lib-download.adb | 19 + src/alire/alire-os_lib-download.ads | 12 +- src/alire/alire-spawn.adb | 50 ++ src/alire/alire-spawn.ads | 3 + src/alr/alr-commands-self_update.adb | 541 ++++++++++++++++++ src/alr/alr-commands-self_update.ads | 44 ++ src/alr/alr-commands.adb | 2 + testsuite/tests/self_update/latest/test.py | 31 + testsuite/tests/self_update/latest/test.yaml | 5 + .../self_update/specific_version/test.py | 66 +++ .../self_update/specific_version/test.yaml | 5 + 25 files changed, 885 insertions(+), 12 deletions(-) create mode 160000 deps/resources create mode 100644 src/alr/alr-commands-self_update.adb create mode 100644 src/alr/alr-commands-self_update.ads create mode 100644 testsuite/tests/self_update/latest/test.py create mode 100644 testsuite/tests/self_update/latest/test.yaml create mode 100644 testsuite/tests/self_update/specific_version/test.py create mode 100644 testsuite/tests/self_update/specific_version/test.yaml diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 13938528..c77912db 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -30,7 +30,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 with: - submodules: true + submodules: recursive - name: OS information for ${{ matrix.tag }} uses: mosteo-actions/docker-run@v2 diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index b3de6773..047855bf 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -67,7 +67,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 with: - submodules: true + submodules: recursive # Install GNAT, we only need a system compiler for Ubuntu ARM until we have # Alire for ARM64/Linux (on Ubuntu 22.04) release. @@ -114,6 +114,7 @@ jobs: env: BRANCH: ${{ github.base_ref }} INDEX: "" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Ascertain whether alr can run without the toolchain that built it diff --git a/.github/workflows/ci-toolchain.yml b/.github/workflows/ci-toolchain.yml index a40f3aa4..78fb412f 100644 --- a/.github/workflows/ci-toolchain.yml +++ b/.github/workflows/ci-toolchain.yml @@ -76,7 +76,7 @@ jobs: - name: Check out uses: actions/checkout@v4 with: - submodules: true + submodules: recursive # Use a stock alr to make the latest toolchain available @@ -144,3 +144,5 @@ jobs: - name: Run testsuite # But ensure a new alr is not build run: scripts/ci-github.sh build=false shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci-unsupported.yml b/.github/workflows/ci-unsupported.yml index 4642836f..e34d6b98 100644 --- a/.github/workflows/ci-unsupported.yml +++ b/.github/workflows/ci-unsupported.yml @@ -22,7 +22,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 with: - submodules: true + submodules: recursive - name: Install FSF toolchain uses: alire-project/alr-install@v2 @@ -40,6 +40,7 @@ jobs: env: BRANCH: ${{ github.base_ref }} ALIRE_DISABLE_DISTRO: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload logs (if failed) if: failure() diff --git a/.github/workflows/tarball-full.yml b/.github/workflows/tarball-full.yml index cc49c54a..cecde55e 100644 --- a/.github/workflows/tarball-full.yml +++ b/.github/workflows/tarball-full.yml @@ -27,7 +27,7 @@ jobs: - name: Check out repository uses: actions/checkout@v2 with: - submodules: true + submodules: recursive - name: Get release version id: get_version @@ -50,4 +50,4 @@ jobs: upload_url: ${{ steps.get_release.outputs.upload_url }} asset_path: alr-${{ steps.get_version.outputs.version-without-v }}.zip asset_name: alr-${{ steps.get_version.outputs.version-without-v }}-full-sources.zip - asset_content_type: application/zip \ No newline at end of file + asset_content_type: application/zip diff --git a/.gitmodules b/.gitmodules index ca9f5279..42219303 100644 --- a/.gitmodules +++ b/.gitmodules @@ -72,3 +72,6 @@ [submodule "deps/templates-parser"] path = deps/templates-parser url = https://github.com/alire-project/templates-parser +[submodule "deps/resources"] + path = deps/resources + url = https://github.com/alire-project/resources diff --git a/alire.toml b/alire.toml index 95e52597..d11f68ec 100644 --- a/alire.toml +++ b/alire.toml @@ -27,14 +27,15 @@ gnatcoll = "^21" lml = "~0.1" minirest = "~0.3" optional = "~0.1.1" +resources = "~0.1" semantic_versioning = "^3.0" simple_logging = "^2.0" si_units = "~0.2.2" +spdx = "~0.2" stopwatch = "~0.1" templates_parser = "^24" # Latests pre-Ada 2022 version toml_slicer = "~0.1" uri_ada = "^2.0" -spdx = "~0.2" [gpr-set-externals] CLIC_LIBRARY_TYPE="static" # Has problems with "static-pic" for some reason @@ -101,6 +102,10 @@ commit = "9a9c660f9c6f27f5ef75417e7fac7061dff14d78" url = "https://github.com/mosteo/optional.git" commit = "7b8132a09a6c3c467409ab15d34fac605b1e5711" +[pins.resources] +url = "https://github.com/alire-project/resources" +commit = "d3a83f35d7c0b586119fc19a568289cf21c87aa0" + [pins.semantic_versioning] url = "https://github.com/alire-project/semantic_versioning" commit = "4861e32bd8a2f0df038d3ecc9a72b6381e7a34cc" diff --git a/alr.gpr b/alr.gpr index c55c0060..8a538ac4 100644 --- a/alr.gpr +++ b/alr.gpr @@ -3,6 +3,7 @@ with "ada_toml"; with "alire"; with "alire_common"; with "ajunitgen"; +with "resources"; with "semantic_versioning"; with "simple_logging"; with "uri_ada"; diff --git a/alr_env.gpr b/alr_env.gpr index 219775f2..6c22102c 100644 --- a/alr_env.gpr +++ b/alr_env.gpr @@ -23,6 +23,7 @@ aggregate project Alr_Env is "deps/lml", "deps/minirest", "deps/optional", + "deps/resources", "deps/semantic_versioning", "deps/si_units", "deps/simple_logging", diff --git a/deps/resources b/deps/resources new file mode 160000 index 00000000..d3a83f35 --- /dev/null +++ b/deps/resources @@ -0,0 +1 @@ +Subproject commit d3a83f35d7c0b586119fc19a568289cf21c87aa0 diff --git a/doc/user-changes.md b/doc/user-changes.md index 1a503c70..ec90ca99 100644 --- a/doc/user-changes.md +++ b/doc/user-changes.md @@ -74,6 +74,23 @@ For backwards compatibility, running `alr test` without a `[test]` section in the manifest will still run local test actions, but they should be considered deprecated. The remote testing capabilities of `alr test` have been removed. +### New self-update helper + +The `alr self-update` command will help users update the Alire binary more +easily. It takes several optional command line flags: + +- `--location=` to specify where to install the new binary +- `--release=` to download and install a specific version (provided + that Alire builds binaries for this version on your platform) +- `--nightly` to install a pre-release version of Alire. + + **Disclaimer**: nightly versions may have incomplete features, unresolved + bugs and may delete features or break compatibility without warning. + +On Windows, updating the binary will launch a separate console window to +perform the update. This is expected behavior, needed because Windows does not +allow us to overwrite a running binary easily. + ## Release `2.1` ### New `--format` global switch to produce structured output diff --git a/src/alire/alire-features.ads b/src/alire/alire-features.ads index 0afd8225..82864e8b 100644 --- a/src/alire/alire-features.ads +++ b/src/alire/alire-features.ads @@ -15,6 +15,9 @@ package Alire.Features is -- former with a warning during our next major release to ease transition. -- Likewise for the -c/--config switch + Self_Update_Cmd : constant On_Version := +"3.0.0-dev"; + -- Used to warn when users downgrade alr using the `self-update` command + package Index is -- Features referring to the index version diff --git a/src/alire/alire-github.adb b/src/alire/alire-github.adb index e9ab5f20..791079cc 100644 --- a/src/alire/alire-github.adb +++ b/src/alire/alire-github.adb @@ -23,6 +23,8 @@ package body Alire.GitHub is Repos : constant String := "repos"; Pulls : constant String := "pulls"; + Releases : constant String := "releases"; + Latest : constant String := "latest"; ------------------- -- Community_API -- @@ -33,6 +35,13 @@ package body Alire.GitHub is / Index.Community_Organization / Index.Community_Repo_Name); + -------------------- + -- Alire_Repo_API -- + -------------------- + + function Alire_Repo_API return String + is (Repos / Index.Community_Organization / "alire"); + ----------------- -- JSON_Escape -- ----------------- @@ -452,4 +461,54 @@ package body Alire.GitHub is -- removing the draft status we'll get a notification? end Request_Review; + ------------------------------ + -- Get_Latest_Alire_Release -- + ------------------------------ + + function Get_Latest_Alire_Release return String is + Response : constant GNATCOLL.JSON.JSON_Value := + API_Call (Alire_Repo_API / Releases / Latest); + -- public endpoint + begin + return Response.Get ("tag_name"); + end Get_Latest_Alire_Release; + + -------------------------------- + -- Check_Alire_Binary_Release -- + -------------------------------- + + function Check_Alire_Binary_Release (Tag, Archive : String) return Outcome + is + Response : constant Minirest.Response := + API_Call (Alire_Repo_API / Releases / "tags" / Tag); + begin + if Response.Succeeded then + declare + use GNATCOLL.JSON; + + Data : constant JSON_Value := + GNATCOLL.JSON.Read (Response.Content.Flatten ("")); + Assets : constant JSON_Array := Data.Get ("assets"); + Len : constant Natural := Length (Assets); + begin + if (for some I in 1 .. Len + => Get (Assets, I).Get ("name") = Archive) + then + return Outcome_Success; + else + return + Outcome_Failure + ("could not find artifact '" + & Archive + & "' in release " + & Tag, + Report => False); + end if; + end; + else + return + Outcome_Failure ("could not find release " & Tag, Report => False); + end if; + end Check_Alire_Binary_Release; + end Alire.GitHub; diff --git a/src/alire/alire-github.ads b/src/alire/alire-github.ads index 95b56d90..744cc313 100644 --- a/src/alire/alire-github.ads +++ b/src/alire/alire-github.ads @@ -95,4 +95,15 @@ package Alire.GitHub is return Boolean; -- Check that a user exists in GitHub + function Get_Latest_Alire_Release return String; + -- Get the tag name for the latest Alire GitHub release + + function Check_Alire_Binary_Release (Tag, Archive : String) return Outcome; + -- Check that a prebuilt archive exists given a tag name and an archive + -- name, for ex: + -- Check_Alire_Binary ("v2.1.0", "alr-2.1.0-bin-aarch64-linux.zip") + -- + -- In case of error, the resulting Outcome will contain an error message + -- specifying whether the release or the specific archive was not found. + end Alire.GitHub; diff --git a/src/alire/alire-os_lib-download.adb b/src/alire/alire-os_lib-download.adb index c287b61e..5f104a3d 100644 --- a/src/alire/alire-os_lib-download.adb +++ b/src/alire/alire-os_lib-download.adb @@ -2,6 +2,7 @@ with Ada.Directories; with Alire.Errors; with Alire.OS_Lib.Subprocess; +with Alire.Platforms.Current; with GNATCOLL.VFS; @@ -31,6 +32,7 @@ package body Alire.OS_Lib.Download is Empty_Vector & URL & "--location" & -- allow for redirects at the remote host + "--fail" & -- fail fast with no output on HTTP errors (if Log_Level < Trace.Info then Empty_Vector & "--silent" else Empty_Vector & "--progress-bar") & @@ -43,4 +45,21 @@ package body Alire.OS_Lib.Download is return Alire.Errors.Get (E); end File; + --------------------- + -- Mark_Executable -- + --------------------- + + procedure Mark_Executable (Path : Any_Path) is + package Plat renames Alire.Platforms; + begin + case Plat.Current.Operating_System is + when Plat.FreeBSD | Plat.OpenBSD | Plat.Linux | Plat.MacOS => + Alire.OS_Lib.Subprocess.Checked_Spawn + ("chmod", Empty_Vector & "+x" & Path); + + when Plat.Windows | Plat.OS_Unknown => + null; + end case; + end Mark_Executable; + end Alire.OS_Lib.Download; diff --git a/src/alire/alire-os_lib-download.ads b/src/alire/alire-os_lib-download.ads index b58649ec..28a7a14f 100644 --- a/src/alire/alire-os_lib-download.ads +++ b/src/alire/alire-os_lib-download.ads @@ -1,10 +1,8 @@ - package Alire.OS_Lib.Download is - function File (URL : String; - Filename : Any_Path; - Folder : Directory_Path) - return Outcome; + function File + (URL : String; Filename : Any_Path; Folder : Directory_Path) + return Outcome; -- Download a single file using `curl` -- -- Specifically, uses 'curl --location --silent --output ' @@ -15,4 +13,8 @@ package Alire.OS_Lib.Download is -- wish to use 'Utils.Tools.Check_Tool'), nor that the destination file -- doesn't already exist. + procedure Mark_Executable (Path : Any_Path); + -- Mark a downloaded binary as executable. On macOS, also removes the + -- quarantine attribute. + end Alire.OS_Lib.Download; diff --git a/src/alire/alire-spawn.adb b/src/alire/alire-spawn.adb index e0560fbb..778c54aa 100644 --- a/src/alire/alire-spawn.adb +++ b/src/alire/alire-spawn.adb @@ -1,6 +1,9 @@ +with Alire.Settings.Edit; with Alire_Early_Elaboration; with Alire.OS_Lib.Subprocess; +with CLIC.User_Input; + package body Alire.Spawn is ------------- @@ -108,4 +111,51 @@ package body Alire.Spawn is ); end Gprinstall; + ----------------------------- + -- Recreate_Global_Options -- + ----------------------------- + + function Recreate_Global_Options return AAA.Strings.Vector is + use AAA.Strings; + package AEE renames Alire_Early_Elaboration; + package UI renames CLIC.User_Input; + + Res : Vector := Empty_Vector; + begin + if AEE.Switch_D then + Res.Append ("-d"); + end if; + + if AEE.Switch_VV then + Res.Append ("-vv"); + elsif AEE.Switch_V then + Res.Append ("-v"); + elsif AEE.Switch_Q then + Res.Append ("-q"); + end if; + + if UI.Not_Interactive then + Res.Append ("-n"); + end if; + + if Alire.Force then + Res.Append ("-f"); + end if; + + if not CLIC.TTY.Color_Enabled then + Res.Append ("--no-color"); + end if; + + if not CLIC.TTY.Is_TTY then + Res.Append ("--no-tty"); + end if; + + if not Alire.Settings.Edit.Is_At_Default_Dir then + Res.Append + (String'("""--settings=" & Alire.Settings.Edit.Path & """")); + end if; + + return Res; + end Recreate_Global_Options; + end Alire.Spawn; diff --git a/src/alire/alire-spawn.ads b/src/alire/alire-spawn.ads index a14fa245..947bdff6 100644 --- a/src/alire/alire-spawn.ads +++ b/src/alire/alire-spawn.ads @@ -49,4 +49,7 @@ package Alire.Spawn is -- --install-name=Release.Milestone.Image \ -- --link-lib-dir=Prefix/bin + function Recreate_Global_Options return AAA.Strings.Vector; + -- Recreate the global options used in this alr invocation (-v, -n, ...) + end Alire.Spawn; diff --git a/src/alr/alr-commands-self_update.adb b/src/alr/alr-commands-self_update.adb new file mode 100644 index 00000000..9850de99 --- /dev/null +++ b/src/alr/alr-commands-self_update.adb @@ -0,0 +1,541 @@ +with Ada.Exceptions; +with Ada.Text_IO; + +with Alire.Features; +with Alire.Spawn; +with Alire.GitHub; +with Alire.Meta; +with Alire.OS_Lib.Download; +with Alire.OS_Lib.Subprocess; +with Alire.Platforms.Current; +with Alire.Platforms.Folders; +with Alire.Utils.Tools; + +with CLIC.User_Input; +with Den.FS; +with GNAT.OS_Lib; +with Resources; +with Semantic_Versioning; + +package body Alr.Commands.Self_Update is + + subtype Any_Path is Alire.Any_Path; + package Dirs renames Alire.Directories; + package Plat renames Alire.Platforms; + package Semver renames Semantic_Versioning; + package UI renames CLIC.User_Input; + use all type Semver.Version; + use all type GNAT_String; + + Releases_Url : constant String := + "https://github.com/alire-project/alire/releases/"; + Base_Url : constant String := Releases_Url & "download/"; + Exe : constant String := Alire.OS_Lib.Exe_Suffix; + Alr_Bin : constant String := "alr" & Exe; + + Magic_Arg_Windows : constant String := + "__magic_arg_windows__" & Alire.Meta.Working_Tree.Commit; + + Abort_With_Success : exception; + -- used to exit from the command without erroring (when the installed + -- version is already the latest, for instance) + + type Tag_Kind is (Latest, Specific_Version, Nightly); + type Tag (Kind : Tag_Kind := Latest) is record + case Kind is + when Latest | Specific_Version => + V : Semver.Version; + + when others => + null; + end case; + end record; + + --------------------- + -- Get_Version_Tag -- + --------------------- + + function Get_Version_Tag (Cmd : Command) return Tag is + begin + if Cmd.Nightly then + return (Kind => Nightly); + elsif Cmd.Release /= null and then Cmd.Release.all /= "" then + declare + V : constant Semver.Version := Semver.Parse (Cmd.Release.all); + V_Img : constant String := Semver.Image (V); + begin + if V_Img /= Cmd.Release.all then + Trace.Detail ("Version string parsed as '" & V_Img & "'"); + end if; + if V < Alire.Version.Current then + Trace.Warning + ("Downgrading to version " + & V_Img + & " (current: " + & Semver.Image (Alire.Version.Current) + & ")"); + if V < Alire.Features.Self_Update_Cmd then + Trace.Warning + ("This version will not have the `self-update` command"); + end if; + end if; + return (Specific_Version, V); + end; + else + declare + Tag_Name : constant String := + Alire.GitHub.Get_Latest_Alire_Release; + V : constant Semver.Version := + Semver.Parse (Tag_Name (Tag_Name'First + 1 .. Tag_Name'Last)); + begin + if Alire.Version.Current.Pre_Release /= "" then + Trace.Info + ("Detected nightly version. Use --" + & Switch_Release + & "=" + & Semver.Image (V) + & " to update to the latest stable release."); + return (Kind => Nightly); + elsif V < Alire.Version.Current then + Trace.Warning + ("You are currently on a preview version (v" + & Semver.Image (Alire.Version.Current) + & ")"); + Trace.Warning + ("Upgrading to latest will downgrade alr to v" + & Semver.Image (V)); + if V < Alire.Features.Self_Update_Cmd then + Trace.Warning + ("This version will not have the `self-update` command"); + end if; + elsif V = Alire.Version.Current then + Trace.Info ("You are already using the latest version of alr!"); + Trace.Info + ("To reinstall the current version, use --" + & Switch_Release + & "=" + & Semver.Image (V)); + raise Abort_With_Success; + end if; + return (Latest, V); + end; + end if; + exception + when Semver.Malformed_Input => + Reportaise_Command_Failed + ("Specified invalid alr version: " & Cmd.Release.all); + end Get_Version_Tag; + + ---------------- + -- Tag_String -- + ---------------- + + function Tag_String (T : Tag) return String + is (case T.Kind is + when Nightly => "nightly", + when Latest | Specific_Version => ("v" & Semver.Image (T.V))); + + -------------------- + -- Version_String -- + -------------------- + + function Version_String (T : Tag) return String + is (case T.Kind is + when Nightly => "nightly", + when Latest | Specific_Version => Semver.Image (T.V)); + + ---------------------- + -- Get_Archive_Name -- + ---------------------- + + function Get_Archive_Name (T : Tag) return String is + use AAA.Strings; + + Arch : constant String := + To_Lower_Case (Plat.Current.Host_Architecture'Image); + Os : constant String := + To_Lower_Case (Plat.Current.Operating_System'Image); + begin + return "alr-" & Version_String (T) & "-bin-" & Arch & "-" & Os & ".zip"; + end Get_Archive_Name; + + ------------------------ + -- Dest_Path_Validate -- + ------------------------ + + function Dest_Path_Validate (Path : Any_Path) return Any_Path is + begin + if Dirs.Is_Directory (Path) then + return Path; + elsif Dirs.Adirs.Simple_Name (Path) /= Alr_Bin then + Reportaise_Command_Failed + ("Invalid location: does not point to an existing directory or a " + & "file named `" + & Alr_Bin + & "`"); + else + declare + Base : constant Any_Path := Dirs.Parent (Path); + begin + if Dirs.Is_Directory (Base) then + return Base; + else + Reportaise_Command_Failed + ("Invalid location: does not point to an existing directory"); + end if; + end; + end if; + end Dest_Path_Validate; + + ----------------- + -- Install_Alr -- + ----------------- + + procedure Install_Alr (Dest_Base, Extracted_Bin : Any_Path) is + use Alire.OS_Lib.Operators; + + Dest_Bin : constant Any_Path := Dest_Base / Alr_Bin; + Backup_Bin : constant Any_Path := + Dest_Base / (Dirs.Temp_Name (Length => 16)); + begin + if Dirs.Is_File (Dest_Bin) then + Trace.Detail ("Backing up the `alr` binary"); + Dirs.Adirs.Rename (Dest_Bin, Backup_Bin); + end if; + + Dirs.Adirs.Copy_File (Extracted_Bin, Dest_Bin); + + Alire.OS_Lib.Download.Mark_Executable (Dest_Bin); + + if Dirs.Is_File (Backup_Bin) then + Dirs.Adirs.Delete_File (Backup_Bin); + end if; + + exception + when E : others => + if Dirs.Is_File (Backup_Bin) then + -- if operation failed and a backup was made, restore previous + Trace.Detail ("Restoring backup of `alr` binary"); + Dirs.Adirs.Rename (Backup_Bin, Dest_Bin); + end if; + + Trace.Error + ("Could not install downloaded binary: " + & Ada.Exceptions.Exception_Message (E)); + Reportaise_Command_Failed + ("Make sure the directory containing the `alr` " + & "binary is writable."); + + end Install_Alr; + + ------------------------------- + -- Windows_Copy_And_Relaunch -- + ------------------------------- + + procedure Windows_Copy_And_Relaunch + (Cmd : Command; Dest_Base, Exe_Path : Any_Path) + with Pre => Plat.Current.On_Windows + is + -- Windows hack explanation: + -- When we detect that the self update will overwrite the currently + -- running binary, we do a little trick: we copy it to the temp folder + -- and relaunch it with proper arguments. It will then be able to + -- overwrite the old binary. This way, there will be no possible file + -- conflicts, and we will always keep a usable `alr.exe` in the intended + -- location. + -- + -- To detect that we do this step only once, we add a special argument + -- to the command invocation ('Magic_Arg_Windows'), which contains a + -- hash of the build commit (for sanity checking). + + use AAA.Strings; + use Alire.OS_Lib.Operators; + + Copied_Bin : constant Any_Path := + Dirs.Parent (Exe_Path) / (Dirs.Temp_Name (Length => 16) & Exe); + Relaunch_Args : Vector := + Empty_Vector + & "/C" + & "start" + & "Alire Self-updater" + & String'("""" & Copied_Bin & """"); + -- the `start` command in cmd.exe will launch a detached process, in a + -- separate console. In the exception section of `Execute`, we pause the + -- console on exception, to avoid the console flashing away on error. + begin + if (for some C of Copied_Bin => C = '"') + or else (for some C of Dest_Base => C = '"') + or else (Cmd.Release /= null + and then Cmd.Release.all /= "" + and then (for some C of Cmd.Release.all => C = '"')) + then + -- check the strings that we use in the command line to prevent shell + -- injections. paths cannot contain '"' characters, and neither can + -- valid semver version strings, so this should be okay. + Reportaise_Command_Failed + ("'""' character in cmd.exe interpolated string"); + end if; + + begin + Dirs.Adirs.Copy_File (Exe_Path, Copied_Bin); + exception + when E : others => + Trace.Error + ("Failed to copy and relaunch current executable: " + & Ada.Exceptions.Exception_Message (E)); + Reportaise_Command_Failed + ("Update cannot continue. Make sure the directory " + & "containing the `alr` executable is writable."); + end; + + Relaunch_Args.Append (Alire.Spawn.Recreate_Global_Options); + + -- self-update flags + Relaunch_Args.Append (Cmd.Name); + Relaunch_Args.Append + (String'("""--" & Switch_Location & "=" & Dest_Base & """")); + if Cmd.Nightly then + Relaunch_Args.Append (String'("--" & Switch_Nightly)); + elsif Cmd.Release /= null and then Cmd.Release.all /= "" then + Relaunch_Args.Append + (String'("""--" & Switch_Release & "=" & Cmd.Release.all & """")); + end if; + + Relaunch_Args.Append (Magic_Arg_Windows); + Alire.OS_Lib.Subprocess.Checked_Spawn ("cmd.exe", Relaunch_Args); + OS_Lib.Bailout; -- quickly exit after `start` launched the second alr + end Windows_Copy_And_Relaunch; + + -------------------------- + -- Windows_Post_Cleanup -- + -------------------------- + + procedure Windows_Post_Cleanup (Exe_Path : Any_Path) + with Pre => Plat.Current.On_Windows + is + -- When cleaning up the secondary windows invocation, we do some + -- convoluted stuff. We spawn a detached `cmd.exe`, which will wait 1 + -- second for the alr process to terminate (using `ping`), and only + -- then will be able to delete the executable with `del`. + -- + -- Finally, we call `pause` to make the window remain on screen until + -- the user interacts (unless the process is non interactive) + -- + -- The path interpolation in the secondary CMD command should be safe, + -- as it is controlled by us when spawning the secondary process. To + -- prevent shell injections, we still check it only contains characters + -- we allow. + + Exe_Name : constant String := Dirs.Adirs.Simple_Name (Exe_Path); + + C_Arg : GNAT_String := new String'("/C"); + Command : GNAT_String := + new String' + ("ping -n 2 127.0.0.1 > nul & del " + & Exe_Name + & (if UI.Not_Interactive then "" else " & pause")); + + Args : constant GNAT.OS_Lib.Argument_List := (C_Arg, Command); + + Pid : GNAT.OS_Lib.Process_Id; + pragma Unreferenced (Pid); + begin + if not (for all C of Exe_Name + => C in 'a' .. 'z' + or else C in 'A' .. 'Z' + or else C in '0' .. '9' + or else C = '.' + or else C = '-') + then + raise Program_Error; + end if; + + Dirs.Adirs.Set_Directory (Dirs.Parent (Exe_Path)); + Pid := GNAT.OS_Lib.Non_Blocking_Spawn ("cmd.exe", Args); + GNAT.OS_Lib.Free (C_Arg); + GNAT.OS_Lib.Free (Command); + end Windows_Post_Cleanup; + + -------------------------------- + -- Windows_Pause_On_Exception -- + -------------------------------- + + procedure Windows_Pause_On_Exception is + -- spawn an asynchronous pause after a delay to leave time for the + -- exception handlers to display error messages + task type Pause_Task; + task body Pause_Task is + begin + delay 1.0; + if not UI.Not_Interactive then + Trace.Info ("Press enter to continue..."); + Trace.Never (Ada.Text_IO.Get_Line); + end if; + end Pause_Task; + type Pause_Task_Access is access all Pause_Task; + Detach : constant Pause_Task_Access := new Pause_Task; + pragma Unreferenced (Detach); + begin + null; + end Windows_Pause_On_Exception; + + ------------- + -- Execute -- + ------------- + + overriding + procedure Execute (Cmd : in out Command; Args : AAA.Strings.Vector) is + use Alire.OS_Lib.Operators; + use AAA.Strings; + + package Find_Exec is new Resources ("alr"); + + Exe_Path : constant String := Find_Exec.Executable_Path; + Dest_Input : constant String := + (if Cmd.Location /= null and then Cmd.Location.all /= "" + then Cmd.Location.all + else Exe_Path); + + Dest_Base : constant Any_Path := Dest_Path_Validate (Dest_Input); + Dest_Bin : constant Any_Path := Dest_Base / Alr_Bin; + begin + Cmd.Forbids_Structured_Output; + + if Plat.Current.On_Windows + and then (Args.Is_Empty or else Args.Last_Element /= Magic_Arg_Windows) + and then Den.FS.Pseudocanonical (Dest_Bin) = Den.Canonical (Exe_Path) + then + Windows_Copy_And_Relaunch (Cmd, Dest_Base, Exe_Path); + end if; + + Alire.Utils.Tools.Check_Tool (Alire.Utils.Tools.Curl); + Alire.Utils.Tools.Check_Tool (Alire.Utils.Tools.Unzip); + + declare + use all type UI.Answer_Kind; + + T : constant Tag := Get_Version_Tag (Cmd); + + Archive : constant String := Get_Archive_Name (T); + Download_Url : constant String := + Base_Url & Tag_String (T) & "/" & Archive; + + Tmp_Dir : constant Any_Path := + Plat.Folders.Temp / Dirs.Temp_Name (16); + Full_Path : constant Any_Path := Tmp_Dir / Archive; + Extract_Dir : constant Any_Path := Tmp_Dir / (Archive & ".extracted"); + + Query_Text : constant String := + (if Dirs.Is_File (Dest_Bin) + then + ("Overwrite the `" + & Alr_Bin + & "` binary at " + & Dest_Input + & " with the downloaded binary?") + else ("Write `" & Alr_Bin & "` to " & Dest_Input & "?")); + + Release_Status : constant Alire.Outcome := + Alire.GitHub.Check_Alire_Binary_Release (Tag_String (T), Archive); + Download_Result : Alire.Outcome; + Proceed : UI.Answer_Kind; + begin + if not Release_Status.Success then + Reportaise_Command_Failed (Release_Status.Message); + end if; + + Dirs.Adirs.Create_Directory (Tmp_Dir); + + Download_Result := + Alire.OS_Lib.Download.File (Download_Url, Archive, Tmp_Dir); + + if not Download_Result.Success then + Trace.Error ("Could not download alr for this platform"); + Reportaise_Command_Failed + ("Check that you are connected to the internet"); + end if; + + Trace.Info ("Successfully downloaded " & Archive); + Dirs.Adirs.Create_Directory (Extract_Dir); + Alire.OS_Lib.Subprocess.Checked_Spawn + ("unzip", Empty_Vector & "-q" & Full_Path & "-d" & Extract_Dir); + + Proceed := + UI.Query + (Query_Text, + (UI.Yes | UI.No => True, UI.Always => False), + UI.Yes); + + if Proceed = UI.Yes then + Install_Alr (Dest_Base, Extract_Dir / "bin" / Alr_Bin); + Trace.Info (""); + Alire.Put_Success ("Updated alr [" & Tag_String (T) & "]"); + Alire.Put_Info + ("Check " + & Releases_Url + & " to see the changes in this version"); + end if; + + Trace.Detail ("Cleaning up temporaries..."); + -- delete the downloaded files + Dirs.Delete_Tree (Tmp_Dir); + + if Plat.Current.On_Windows + and then not Args.Is_Empty + and then Args.Last_Element = Magic_Arg_Windows + then + Windows_Post_Cleanup (Exe_Path); + end if; + end; + exception + when Abort_With_Success => + null; + when others => + if Plat.Current.On_Windows + and then not Args.Is_Empty + and then Args.Last_Element = Magic_Arg_Windows + then + -- pause (asynchronously) to give the user time to read error + -- messages + Windows_Pause_On_Exception; + end if; + raise; + end Execute; + + -------------------- + -- Setup_Switches -- + -------------------- + + overriding + procedure Setup_Switches + (Cmd : in out Command; + Config : in out CLIC.Subcommand.Switches_Configuration) + is + use CLIC.Subcommand; + begin + Define_Switch + (Config, + Cmd.Location'Access, + "", + "--" & Switch_Location & "=", + "Specify where to install (and overwrite) the alr binary" + & " [default: the current path of alr, if found]", + Argument => ""); + + Define_Switch + (Config, + Cmd.Nightly'Access, + "", + "--" & Switch_Nightly, + "Download and install the most recent nightly version of alr"); + + Define_Switch + (Config, + Cmd.Release'Access, + "", + "--" & Switch_Release & "=", + "Download a specific version of alr", + Argument => ""); + end Setup_Switches; + +end Alr.Commands.Self_Update; diff --git a/src/alr/alr-commands-self_update.ads b/src/alr/alr-commands-self_update.ads new file mode 100644 index 00000000..07616c7d --- /dev/null +++ b/src/alr/alr-commands-self_update.ads @@ -0,0 +1,44 @@ +package Alr.Commands.Self_Update is + use type AAA.Strings.Vector; + + type Command is new Commands.Command with private; + + overriding + function Name (Cmd : Command) return CLIC.Subcommand.Identifier + is ("self-update"); + + overriding + procedure Execute (Cmd : in out Command; Args : AAA.Strings.Vector); + + overriding + function Short_Description (Cmd : Command) return String + is ("Self-update the alire binary"); + + overriding + function Long_Description (Cmd : Command) return AAA.Strings.Vector + is (AAA.Strings.Empty_Vector + & "Update the Alire binary to the latest version, or to a version " + & "specified on the command line."); + + overriding + function Usage_Custom_Parameters (Cmd : Command) return String + is (""); + + overriding + procedure Setup_Switches + (Cmd : in out Command; + Config : in out CLIC.Subcommand.Switches_Configuration); + +private + + Switch_Location : constant String := "location"; + Switch_Nightly : constant String := "nightly"; + Switch_Release : constant String := "release"; + + type Command is new Commands.Command with record + Nightly : aliased Boolean := False; + Location : aliased GNAT_String; + Release : aliased GNAT_String; + end record; + +end Alr.Commands.Self_Update; diff --git a/src/alr/alr-commands.adb b/src/alr/alr-commands.adb index 9dc22bb9..a7fc8e00 100644 --- a/src/alr/alr-commands.adb +++ b/src/alr/alr-commands.adb @@ -41,6 +41,7 @@ with Alr.Commands.Printenv; with Alr.Commands.Publish; with Alr.Commands.Run; with Alr.Commands.Search; +with Alr.Commands.Self_Update; with Alr.Commands.Settings; with Alr.Commands.Show; with Alr.Commands.Test; @@ -766,6 +767,7 @@ begin Sub_Cmd.Register ("General", new Config.Command); Sub_Cmd.Register ("General", new Install.Command); Sub_Cmd.Register ("General", new Toolchain.Command); + Sub_Cmd.Register ("General", new Self_Update.Command); Sub_Cmd.Register ("General", new Version.Command); Sub_Cmd.Register ("Index", new Get.Command); diff --git a/testsuite/tests/self_update/latest/test.py b/testsuite/tests/self_update/latest/test.py new file mode 100644 index 00000000..071b7a06 --- /dev/null +++ b/testsuite/tests/self_update/latest/test.py @@ -0,0 +1,31 @@ +""" +Perform a `self-update` to latest (do NOT replace the current binary) +""" + +from drivers.alr import run_alr +from drivers.helpers import exe_name, MockCommand +import os + +v_init = run_alr("version").out + +curl_script = """ +import os +import subprocess +import sys + +env2 = os.environ.copy() +env2["PATH"] = env2["PATH"].split(os.pathsep, 1)[1] +token_header = [] +if "GITHUB_TOKEN" in os.environ: + token_header = ["-H", f"Authorization: Bearer {os.environ['GITHUB_TOKEN']}"] +subprocess.call(["curl", *token_header, *sys.argv[1:]], env=env2) +""" + +with MockCommand("curl", curl_script, "curl_override"): + run_alr("self-update", "--location=.") + +assert os.path.exists(exe_name("alr")) + +assert run_alr("version").out == v_init # ensure the main alr is unchanged + +print("SUCCESS") diff --git a/testsuite/tests/self_update/latest/test.yaml b/testsuite/tests/self_update/latest/test.yaml new file mode 100644 index 00000000..ec663661 --- /dev/null +++ b/testsuite/tests/self_update/latest/test.yaml @@ -0,0 +1,5 @@ +driver: python-script +indexes: + compiler_only_index: {} +control: + - [SKIP, "skip_network", "Network disabled"] diff --git a/testsuite/tests/self_update/specific_version/test.py b/testsuite/tests/self_update/specific_version/test.py new file mode 100644 index 00000000..e4741d19 --- /dev/null +++ b/testsuite/tests/self_update/specific_version/test.py @@ -0,0 +1,66 @@ +""" +Perfom a self-update on a copy of the current executable +""" + +import drivers.alr +from drivers.helpers import exe_name, MockCommand, run, shutil +from drivers.asserts import assert_substring +import time +import os + +v_init = drivers.alr.run_alr("version").out + +shutil.copy(os.environ["ALR_PATH"], ".") + +curl_script = """\ +import os +import subprocess +import sys + +env2 = os.environ.copy() +env2["PATH"] = env2["PATH"].split(os.pathsep, 1)[1] +token_header = [] +if "GITHUB_TOKEN" in os.environ and any( + a.startswith("https://api.github.com") for a in sys.argv[1:] +): + token_header = ["-H", f"Authorization: Bearer {os.environ['GITHUB_TOKEN']}"] +subprocess.call(["curl", *token_header, *sys.argv[1:]], env=env2) +""" + + +def run_alr(args: list[str], expect_success: bool = True) -> str: + p = run( + [f".{os.sep}{exe_name('alr')}", "-n", *args], + capture_output=True, + ) + assert expect_success == ( + p.returncode == 0 + ), f"""stdout: {p.stdout.decode(errors="replace")} +stderr: {p.stderr.decode(errors="replace")}""" + return p.stdout.decode(errors="replace") + + +with MockCommand("curl", curl_script, "curl_override"): + out = run_alr(["self-update", "--release=2.1.0"]) + + if ( + "alr-2.1.0-bin-aarch64-linux.zip" in out + and __import__("platform").freedesktop_os_release().get("VERSION_ID") == "22.04" + ): # HACK: no working alr 2.1.0 for ubuntu 22.04 ARM + print("SUCCESS") + __import__("sys").exit() + + out = None + for i in range(10): + # on windows, the self-update process runs detached, + # so we run this in a loop until it's done (or failed) + out = run_alr(["--version"]) + if "2.1" in out: + break + time.sleep(1) + +assert_substring("2.1", out) + +assert drivers.alr.run_alr("version").out == v_init # ensure the main alr is unchanged + +print("SUCCESS") diff --git a/testsuite/tests/self_update/specific_version/test.yaml b/testsuite/tests/self_update/specific_version/test.yaml new file mode 100644 index 00000000..ec663661 --- /dev/null +++ b/testsuite/tests/self_update/specific_version/test.yaml @@ -0,0 +1,5 @@ +driver: python-script +indexes: + compiler_only_index: {} +control: + - [SKIP, "skip_network", "Network disabled"] -- 2.39.5