From 975f775ca90fbff8d98611cb1c9d6d07dfacb979 Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Mon, 23 Feb 2026 12:02:56 +1030 Subject: [PATCH 1/9] Improvements for bash associative arrays --- .../Scripting/Bash/BashScriptBootstrapper.cs | 10 +- .../Features/Scripting/Bash/Bootstrap.sh | 59 +++-- .../Fixtures/Bash/BashFixture.cs | 220 +++++++++++++++++- .../Bash/Scripts/count-octopus-parameters.sh | 14 ++ 4 files changed, 269 insertions(+), 34 deletions(-) create mode 100644 source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh diff --git a/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs b/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs index faf4b541ba..e3b977b47e 100644 --- a/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs +++ b/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs @@ -65,13 +65,7 @@ public static string PrepareConfigurationFile(string workingDirectory, IVariable File.SetAttributes(configurationFile, FileAttributes.Hidden); return configurationFile; } - - static string EncodeAsHex(string value) - { - var bytes = Encoding.UTF8.GetBytes(value); - return BitConverter.ToString(bytes).Replace("-", ""); - } - + static (string encrypted, string iv) GetEncryptedVariablesKvp(IVariables variables) { var sb = new StringBuilder(); @@ -80,7 +74,7 @@ static string EncodeAsHex(string value) .Where(v => !ScriptVariables.IsBuildInformationVariable(v.Key))) { var value = variable.Value ?? "nul"; - sb.Append($"{EncodeAsHex(variable.Key)}").Append("$").AppendLine(EncodeAsHex(value)); + sb.Append(EncodeValue(variable.Key)).Append("$").AppendLine(EncodeValue(value)); } var encrypted = VariableEncryptor.Encrypt(sb.ToString()); diff --git a/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh b/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh index 4c4c587044..40dc195b26 100644 --- a/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh +++ b/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh @@ -268,38 +268,53 @@ function decrypt_and_parse_variables { decrypted=$(decrypt_variable "$encrypted" "$iv") declare -gA octopus_parameters=() + local -a b64_keys=() + local -a b64_values=() local -a key_byte_lengths=() local -a value_byte_lengths=() - local -a hex_parts=() - while IFS='$' read -r hex_key hex_value; do - hex_value="${hex_value//$'\n'/}" - key_byte_lengths+=( $(( ${#hex_key} / 2 )) ) - value_byte_lengths+=( $(( ${#hex_value} / 2 )) ) - hex_parts+=( "${hex_key}${hex_value}" ) + local b64_key b64_value key_pad val_pad + while IFS='$' read -r b64_key b64_value; do + b64_value="${b64_value//$'\n'/}" + [[ -z "$b64_key" ]] && continue + + if [[ "$b64_key" == *"==" ]]; then key_pad=2 + elif [[ "$b64_key" == *"=" ]]; then key_pad=1 + else key_pad=0; fi + + if [[ "$b64_value" == *"==" ]]; then val_pad=2 + elif [[ "$b64_value" == *"=" ]]; then val_pad=1 + else val_pad=0; fi + + b64_keys+=("$b64_key") + b64_values+=("$b64_value") + key_byte_lengths+=($(( ${#b64_key} / 4 * 3 - key_pad ))) + value_byte_lengths+=($(( ${#b64_value} / 4 * 3 - val_pad ))) done <<< "$decrypted" - local concatenated_hex - concatenated_hex=$(printf "%s" "${hex_parts[@]}") + [[ ${#b64_keys[@]} -eq 0 ]] && return - exec 3< <(echo -n "$concatenated_hex" | xxd -r -p) + local keys_tmp values_tmp + keys_tmp=$(mktemp) || { echo "Failed to create temp file for octopus_parameters" >&2; return 1; } + values_tmp=$(mktemp) || { rm -f "$keys_tmp"; echo "Failed to create temp file for octopus_parameters" >&2; return 1; } + trap 'rm -f "$keys_tmp" "$values_tmp"' RETURN - local idx - for idx in "${!key_byte_lengths[@]}"; do - local key_byte_len="${key_byte_lengths[idx]}" - local value_byte_len="${value_byte_lengths[idx]}" - local decoded_key decoded_value + printf '%s\n' "${b64_keys[@]}" | base64 -d > "$keys_tmp" || { echo "base64 decode failed for octopus_parameters keys" >&2; return 1; } + printf '%s\n' "${b64_values[@]}" | base64 -d > "$values_tmp" || { echo "base64 decode failed for octopus_parameters values" >&2; return 1; } - LC_ALL=C read -r -N "$key_byte_len" decoded_key <&3 - LC_ALL=C read -r -N "$value_byte_len" decoded_value <&3 + exec 3< "$keys_tmp" + exec 4< "$values_tmp" + local idx decoded_key decoded_value + for idx in "${!b64_keys[@]}"; do + LC_ALL=C read -r -N "${key_byte_lengths[idx]}" decoded_key <&3 + LC_ALL=C read -r -N "${value_byte_lengths[idx]}" decoded_value <&4 [[ "$decoded_value" == "nul" ]] && decoded_value="" - if [[ -n "$decoded_key" ]]; then - octopus_parameters["$decoded_key"]="$decoded_value" - fi + [[ -n "$decoded_key" ]] && octopus_parameters["$decoded_key"]="$decoded_value" done exec 3<&- + exec 4<&- } # ----------------------------------------------------------------------------- @@ -370,11 +385,7 @@ bashParametersArrayFeatureToggle=#### BashParametersArrayFeatureToggle #### if [ "$bashParametersArrayFeatureToggle" = true ]; then if (( ${BASH_VERSINFO[0]:-0} > 4 || (${BASH_VERSINFO[0]:-0} == 4 && ${BASH_VERSINFO[1]:-0} > 2) )); then - if command -v xxd > /dev/null; then - decrypt_and_parse_variables "#### VARIABLESTRING.ENCRYPTED ####" "#### VARIABLESTRING.IV ####" - else - echo "xxd is not installed, this is required to use octopus_parameters" - fi + decrypt_and_parse_variables "#### VARIABLESTRING.ENCRYPTED ####" "#### VARIABLESTRING.IV ####" else echo "Bash version 4.2 or later is required to use octopus_parameters" fi diff --git a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs index e54c051a89..e74544d9fa 100644 --- a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs +++ b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs @@ -1,6 +1,8 @@ -using System; +using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using Calamari.Common.Features.Processes; @@ -335,6 +337,8 @@ public void ShouldSupportStrictVariableUnset(FeatureToggle? featureToggle) static string specialCharacters => "! \" # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \\ ] ^ _ ` { | } ~ \n\u00b1 \u00d7 \u00f7 \u2211 \u220f \u2202 \u221e \u222b \u2248 \u2260 \u2264 \u2265 \u221a \u221b \u2206 \u2207 \u221d \n$ \u00a2 \u00a3 \u00a5 \u20ac \u20b9 \u20a9 \u20b1 \u20aa \u20bf \n• ‣ … ′ ″ ‘ ’ “ ” ‽ ¡ ¿ – — ― \n( ) [ ] { } ⟨ ⟩ « » ‘ ’ “ ” \n\u2190 \u2191 \u2192 \u2193 \u2194 \u2195 \u2196 \u2197 \u2198 \u2199 \u2b05 \u2b06 \u2b07 \u27a1 \u27f3 \nα β γ δ ε ζ η θ ι κ λ μ ν ξ ο π ρ σ τ υ φ χ ψ ω \n\u00a9 \u00ae \u2122 § ¶ † ‡ µ #"; + [TestCase(FeatureToggle.BashParametersArrayFeatureToggle)] + [TestCase(null)] [RequiresBashDotExeIfOnWindows] public void ShouldBeAbleToEnumerateVariableValues(FeatureToggle? featureToggle) { @@ -354,7 +358,29 @@ public void ShouldBeAbleToEnumerateVariableValues(FeatureToggle? featureToggle) ["VariableName \n 11"] = "Value \n 11", ["VariableName.prop.anotherprop 12"] = "Value.prop.12", ["VariableName`prop`anotherprop` 13"] = "Value`prop`13", - [specialCharacters] = specialCharacters + [specialCharacters] = specialCharacters, + // Emoji / 4-byte UTF-8 codepoints + ["EmojiKey 🎉💡🔥"] = "EmojiValue 😀🌍🚀", + // CJK (Chinese / Japanese / Korean) + ["CJK 中文 日本語 한국어"] = "中文值 你好世界", + // Arabic RTL text + ["Arabic مفتاح"] = "قيمة عربية", + // Bash command-injection attempts in both key and value + ["InjectionAttempt $(echo injected)"] = "$(echo injected) `echo injected` ${HOME}", + // Empty value + ["EmptyValueKey"] = "", + // Leading and trailing whitespace in value + ["LeadingTrailingSpaces"] = " value padded with spaces ", + // Multiple '=' signs in value (parsers that split on '=' can mis-handle this) + ["MultipleEquals"] = "a=b=c=d", + // Zero-width space (U+200B) – invisible but load-bearing + ["ZeroWidth\u200bKey"] = "zero\u200bwidth\u200bvalue", + // ANSI escape sequence – terminal control injection attempt + ["AnsiEscapeKey"] = "\x1b[31mRed\x1b[0m", + // Supplementary-plane Unicode (mathematical script + musical symbols) + ["SupplementaryPlane 𝒜𝄞"] = "value 𝐀𝁆", + // Combining diacritical mark (NFD 'é' vs NFC U+00E9) + ["CombiningDiacritical Caf\u0301"] = "Caf\u00e9", }.AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle })); output.AssertSuccess(); @@ -383,6 +409,196 @@ public void ShouldBeAbleToEnumerateVariableValues(FeatureToggle? featureToggle) output.AssertOutput("Key: VariableName.prop.anotherprop 12, Value: Value.prop.12"); output.AssertOutput("Key: VariableName`prop`anotherprop` 13, Value: Value`prop`13"); output.AssertOutput($"Key: {specialCharacters}, Value: {specialCharacters}"); + + output.AssertOutput("Key: EmojiKey 🎉💡🔥, Value: EmojiValue 😀🌍🚀"); + output.AssertOutput("Key: CJK 中文 日本語 한국어, Value: 中文值 你好世界"); + output.AssertOutput("Key: Arabic مفتاح, Value: قيمة عربية"); + output.AssertOutput("Key: InjectionAttempt $(echo injected), Value: $(echo injected) `echo injected` ${HOME}"); + output.AssertOutput("Key: EmptyValueKey, Value: "); + output.AssertOutput("Key: LeadingTrailingSpaces, Value: value padded with spaces "); + output.AssertOutput("Key: MultipleEquals, Value: a=b=c=d"); + output.AssertOutput($"Key: ZeroWidth\u200bKey, Value: zero\u200bwidth\u200bvalue"); + output.AssertOutput($"Key: AnsiEscapeKey, Value: \x1b[31mRed\x1b[0m"); + output.AssertOutput($"Key: SupplementaryPlane 𝒜𝄞, Value: value 𝐀𝁆"); + output.AssertOutput($"Key: CombiningDiacritical Caf\u0301, Value: Caf\u00e9"); + } + + [Explicit] + [TestCase(10000, 100000, Description = "5 000 variables (~stress test)")] + [RequiresBashDotExeIfOnWindows] + public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int timeLimitMs) + { + var variables = BuildPerformanceTestVariables(variableCount) + .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); + + var stopwatch = Stopwatch.StartNew(); + var (output, _) = RunScript("count-octopus-parameters.sh", variables); + stopwatch.Stop(); + + var elapsedMs = stopwatch.ElapsedMilliseconds; + TestContext.WriteLine($"[Perf] {variableCount,5} variables → {elapsedMs,6}ms (limit {timeLimitMs / 1000}s)"); + + output.AssertSuccess(); + + var fullOutput = string.Join(Environment.NewLine, output.CapturedOutput.Infos); + if (fullOutput.Contains("Bash version 4.2 or later is required to use octopus_parameters")) + { + Assert.Ignore("Bash 4.2+ required for octopus_parameters; performance assertion skipped."); + return; + } + + // Verify the variable count reported by the script. + var countLine = output.GetOutputForLineContaining("VariableCount="); + var loadedCount = int.Parse(Regex.Match(countLine, @"VariableCount=(\d+)").Groups[1].Value); + loadedCount.Should().BeGreaterThanOrEqualTo( + variableCount, + $"octopus_parameters should contain at least the {variableCount} variables we passed in"); + + // Spot-check: a sentinel variable injected by BuildPerformanceTestVariables must survive the round-trip. + output.AssertOutput("SpotCheck=PerfSentinelValue"); + + elapsedMs.Should().BeLessThan( + timeLimitMs, + $"Loading {variableCount} variables should complete within {timeLimitMs / 1000.0:F0}s"); + } + + // --------------------------------------------------------------------------- + // Realistic variable generator for performance tests. + // + // Produces a distribution that approximates a real Octopus deployment: + // + // Bucket | Share | Name length | Value length | Example + // --------|-------|--------------|----------------|---------------------------- + // Small | 60% | 15–30 chars | 10–40 chars | Project.Name, port numbers + // Medium | 25% | 40–65 chars | 150–300 chars | SQL / Redis connection strings + // Large | 10% | 70–130 chars| 700–950 chars | JSON config blobs + // Huge | 5% | 100–180 chars| 6 000–9 000 chars | PEM certificate + key bundles + // + // All keys are unique (every template appends the loop index). + // A "PerfSentinel" key is added for correctness spot-checks. + // --------------------------------------------------------------------------- + + static Dictionary BuildPerformanceTestVariables(int count) + { + var result = new Dictionary(count + 1); + for (var i = 0; i < count; i++) + { + var (key, value) = (i % 20) switch + { + < 12 => (PerfSmallKey(i), PerfSmallValue(i)), + < 17 => (PerfMediumKey(i), PerfMediumValue(i)), + < 19 => (PerfLargeKey(i), PerfLargeValue(i)), + _ => (PerfHugeKey(i), PerfHugeValue(i)), + }; + result[key] = value; + } + result["PerfSentinel"] = "PerfSentinelValue"; + return result; + } + + // Small (60%): names ~15–30 chars, values ~10–40 chars + static string PerfSmallKey(int i) => (i % 12) switch + { + 0 => $"Project.Name.{i:D4}", + 1 => $"Environment.Region.{i:D4}", + 2 => $"Application.Version.{i:D4}", + 3 => $"Config.Timeout.Seconds.{i:D4}", + 4 => $"Deploy.Mode.{i:D4}", + 5 => $"Service.Port.{i:D4}", + 6 => $"Feature.{i:D4}.Enabled", + 7 => $"Build.Number.{i:D4}", + 8 => $"Cluster.Zone.{i:D4}", + 9 => $"Agent.Pool.{i:D4}", + 10 => $"Release.Channel.{i:D4}", + _ => $"Tag.Value.{i:D4}", + }; + + static string PerfSmallValue(int i) => (i % 12) switch + { + 0 => $"production-{i % 5}", + 1 => $"us-{(i % 2 == 0 ? "east" : "west")}-{i % 3 + 1}", + 2 => $"v{i % 10}.{i % 100 + 1}.{i % 1000}", + 3 => $"{i % 120 + 10}", + 4 => i % 2 == 0 ? "blue-green" : "rolling", + 5 => $"{8080 + i % 100}", + 6 => i % 2 == 0 ? "true" : "false", + 7 => $"build-{i:D6}", + 8 => $"zone-{(char)('a' + i % 3)}", + 9 => $"pool-{i % 4}", + 10 => i % 2 == 0 ? "stable" : "beta", + _ => $"tag-{i % 20:D3}", + }; + + // Medium (25%): names ~40–65 chars, values ~150–300 chars + static string PerfMediumKey(int i) => (i % 5) switch + { + 0 => $"Application.Database.Primary.ConnectionString.{i:D4}", + 1 => $"Azure.Storage.Account{i % 3}.AccessKey.{i:D4}", + 2 => $"Service.Authentication.BearerToken.Endpoint.{i:D4}", + 3 => $"Infrastructure.Cache.Redis{i % 2}.ConnectionString.{i:D4}", + _ => $"Octopus.Action.Package[MyApp.Service{i % 5}].FeedUri.{i:D4}", + }; + + static string PerfMediumValue(int i) => (i % 5) switch + { + 0 => $"Server=tcp:sql{i % 3}.database.windows.net,1433;Initial Catalog=AppDb{i % 10};User Id=svc_app;Password=P@ssw0rd{i:D4}!;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;", + 1 => $"DefaultEndpointsProtocol=https;AccountName=storage{i % 5}acct;AccountKey=dGhpcyBpcyBhIGZha2Uga2V5IGZvciBzdG9yYWdlIGFjY291bnQ{i:D4}==;EndpointSuffix=core.windows.net", + 2 => $"https://login.microsoftonline.com/{i:D8}-aaaa-bbbb-cccc-{i:D12}/oauth2/v2.0/token", + 3 => $"cache{i % 2}.redis.cache.windows.net:6380,password=cacheSecret{i:D4}==,ssl=true,abortConnect=false,connectTimeout=5000,syncTimeout=3000", + _ => $"https://nuget.pkg.github.com/MyOrganisation{i % 3}/index.json?api-version=6.0", + }; + + // Large (10%): names ~70–130 chars, values ~700–950 chars (JSON config blobs) + static string PerfLargeKey(int i) => + $"Octopus.Action[Step {i % 10}: Deploy {(i % 2 == 0 ? "Application" : "Infrastructure")} to {(i % 3 == 0 ? "Production" : "Staging")}].Package[MyCompany.Service{i % 5}].Config.{i:D4}"; + + static string PerfLargeValue(int i) => $@"{{ + ""environment"": ""{(i % 2 == 0 ? "production" : "staging")}"", + ""instanceId"": ""{i:D8}"", + ""serviceEndpoints"": {{ + ""auth"": ""https://auth{i % 3}.internal.company.com/oauth/token"", + ""users"": ""https://users{i % 3}.internal.company.com/api/v2"", + ""events"": ""https://events{i % 3}.internal.company.com/stream"" + }}, + ""database"": {{ + ""primary"": ""Server=tcp:primary{i % 2}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_app;Password=P@ssw0rd{i:D6}!;Encrypt=True;Connection Timeout=30;"", + ""replica"": ""Server=tcp:replica{i % 3}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_ro;Password=R3adOnly{i:D6}!;Encrypt=True;Connection Timeout=30;"" + }}, + ""cache"": {{ + ""primary"": ""cache{i % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"", + ""secondary"": ""cache{(i + 1) % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"" + }}, + ""storage"": {{ + ""accountName"": ""storage{i % 5}acct"", + ""containerName"": ""deployments-{i:D4}"", + ""sasToken"": ""sv=2021-12-02&ss=b&srt=co&sp=rwdlacupiytfx&se=2025-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=fakeSignature{i:D6}=="" + }} +}}"; + + // Huge (5%): names ~100–180 chars, values ~6 000–9 000 chars (PEM cert + private key bundle) + static string PerfHugeKey(int i) => + $"Octopus.Action[Step {i % 5}: Long Running {(i % 2 == 0 ? "Database Migration" : "Certificate Rotation")} Task For {(i % 3 == 0 ? "Production-AUS" : "Production-US")} Environment].Package[MyCompany.{(i % 2 == 0 ? "Infrastructure" : "Security")}.Tooling.v{i % 10}].Config.{i:D4}"; + + static string PerfHugeValue(int i) + { + // Each cert line is 60 chars of base64 + 8-digit serial + "==" + newline ≈ 72 chars. + // 60 + 52 + 42 lines across 3 PEM blocks ≈ 154 lines × 72 chars ≈ 11 KB per variable. + const string b64 = "MIIGXTCCBEWgAwIBAgIJAKnmpBuMNbOBMA0GCSqGSIb3DQEBCwUAMIGaMQswCQYD"; // 64 chars + var sb = new StringBuilder(9000); + sb.AppendLine($"# Certificate bundle — slot {i % 3}, serial {i:D8}"); + sb.AppendLine("-----BEGIN CERTIFICATE-----"); + for (var line = 0; line < 60; line++) + sb.AppendLine($"{b64.Substring((i + line) % 4, 60)}{i * 31337 + line * 7:D8}=="); + sb.AppendLine("-----END CERTIFICATE-----"); + sb.AppendLine("-----BEGIN PRIVATE KEY-----"); + for (var line = 0; line < 52; line++) + sb.AppendLine($"{b64.Substring((i + line + 2) % 4, 60)}{i * 99991 + line * 13:D8}Ag=="); + sb.AppendLine("-----END PRIVATE KEY-----"); + sb.AppendLine("-----BEGIN CERTIFICATE-----"); + for (var line = 0; line < 42; line++) + sb.AppendLine($"{b64.Substring((i + line + 1) % 4, 60)}{i * 65537 + line * 11:D8}=="); + sb.AppendLine("-----END CERTIFICATE-----"); + return sb.ToString(); } } diff --git a/source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh b/source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh new file mode 100644 index 0000000000..80828d6d33 --- /dev/null +++ b/source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Used for performance testing of decrypt_and_parse_variables. +# Counts the number of entries in octopus_parameters and prints the total, +# along with a spot-check of a known sentinel key to verify correctness. + +count=0 +for key in "${!octopus_parameters[@]}"; do + count=$(( count + 1 )) +done + +echo "VariableCount=$count" + +# Spot-check: BuildPerformanceTestVariables always injects this sentinel. +echo "SpotCheck=${octopus_parameters[PerfSentinel]}" From 8123aae286c8a045b5247ec0369d34af46d6750b Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Mon, 23 Feb 2026 12:20:32 +1030 Subject: [PATCH 2/9] Don't trigger git guardian... --- .../Fixtures/Bash/BashFixture.cs | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs index e74544d9fa..2d75d603f8 100644 --- a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs +++ b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs @@ -424,19 +424,48 @@ public void ShouldBeAbleToEnumerateVariableValues(FeatureToggle? featureToggle) } [Explicit] - [TestCase(10000, 100000, Description = "5 000 variables (~stress test)")] + [Category("Performance")] + [TestCase( 100, 30_000, Description = "100 variables (~small deployment)")] + [TestCase( 500, 60_000, Description = "500 variables (~medium deployment)")] + [TestCase(1_000, 120_000, Description = "1 000 variables (~large deployment)")] + [TestCase(5_000, 300_000, Description = "5 000 variables (~stress test)")] + [TestCase(20_000, 300_000, Description = "5 000 variables (~stress test)")] [RequiresBashDotExeIfOnWindows] public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int timeLimitMs) { - var variables = BuildPerformanceTestVariables(variableCount) + // Build the realistic payload separately so we can measure its size + // before it gets encrypted and written into the bootstrap script. + var perfVariables = BuildPerformanceTestVariables(variableCount); + var variables = new Dictionary(perfVariables) .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); + var keyBytes = perfVariables.Keys .Select(k => (long)Encoding.UTF8.GetByteCount(k)) .ToArray(); + var valueBytes = perfVariables.Values.Select(v => (long)Encoding.UTF8.GetByteCount(v ?? "")).ToArray(); + var totalKeyBytes = keyBytes.Sum(); + var totalValueBytes = valueBytes.Sum(); + var totalPairBytes = totalKeyBytes + totalValueBytes; + var stopwatch = Stopwatch.StartNew(); var (output, _) = RunScript("count-octopus-parameters.sh", variables); stopwatch.Stop(); var elapsedMs = stopwatch.ElapsedMilliseconds; - TestContext.WriteLine($"[Perf] {variableCount,5} variables → {elapsedMs,6}ms (limit {timeLimitMs / 1000}s)"); + + // ── Structured performance report ──────────────────────────────────── + static string Kb(long b) => $"{b / 1024.0,7:F1} KB"; + static string Fmt(double b) => b >= 1024 ? $"{b / 1024.0:F1} KB" : $"{b:F0} B"; + + TestContext.WriteLine(""); + TestContext.WriteLine("── Payload ─────────────────────────────────────────────────────"); + TestContext.WriteLine($" Variables : {perfVariables.Count,6:N0}"); + TestContext.WriteLine($" Keys : avg {Fmt(keyBytes.Average()),9} │ min {keyBytes.Min(),5} B │ max {keyBytes.Max(),7} B │ total {Kb(totalKeyBytes)}"); + TestContext.WriteLine($" Values : avg {Fmt(valueBytes.Average()),9} │ min {valueBytes.Min(),5} B │ max {valueBytes.Max(),7} B │ total {Kb(totalValueBytes)}"); + TestContext.WriteLine($" Pairs : avg {Fmt(keyBytes.Average() + valueBytes.Average()),9} │ │ total {Kb(totalPairBytes)}"); + TestContext.WriteLine("── Timing ──────────────────────────────────────────────────────"); + TestContext.WriteLine($" Total : {elapsedMs,7} ms (limit {timeLimitMs / 1000} s)"); + TestContext.WriteLine($" Per var : {elapsedMs / (double)perfVariables.Count,9:F2} ms"); + TestContext.WriteLine($" Throughput : {totalPairBytes / 1024.0 / (elapsedMs / 1000.0),7:F1} KB/s"); + TestContext.WriteLine("────────────────────────────────────────────────────────────────"); output.AssertSuccess(); @@ -447,14 +476,12 @@ public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int t return; } - // Verify the variable count reported by the script. var countLine = output.GetOutputForLineContaining("VariableCount="); var loadedCount = int.Parse(Regex.Match(countLine, @"VariableCount=(\d+)").Groups[1].Value); loadedCount.Should().BeGreaterThanOrEqualTo( variableCount, $"octopus_parameters should contain at least the {variableCount} variables we passed in"); - // Spot-check: a sentinel variable injected by BuildPerformanceTestVariables must survive the round-trip. output.AssertOutput("SpotCheck=PerfSentinelValue"); elapsedMs.Should().BeLessThan( @@ -542,7 +569,7 @@ static Dictionary BuildPerformanceTestVariables(int count) static string PerfMediumValue(int i) => (i % 5) switch { 0 => $"Server=tcp:sql{i % 3}.database.windows.net,1433;Initial Catalog=AppDb{i % 10};User Id=svc_app;Password=P@ssw0rd{i:D4}!;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;", - 1 => $"DefaultEndpointsProtocol=https;AccountName=storage{i % 5}acct;AccountKey=dGhpcyBpcyBhIGZha2Uga2V5IGZvciBzdG9yYWdlIGFjY291bnQ{i:D4}==;EndpointSuffix=core.windows.net", + 1 => $"DefaultEndpointsProtocol=https;AccountName=storage{i % 5}acct;AccountKey=FAKE-KEY-NOT-A-SECRET-{i:D4};EndpointSuffix=core.windows.net", 2 => $"https://login.microsoftonline.com/{i:D8}-aaaa-bbbb-cccc-{i:D12}/oauth2/v2.0/token", 3 => $"cache{i % 2}.redis.cache.windows.net:6380,password=cacheSecret{i:D4}==,ssl=true,abortConnect=false,connectTimeout=5000,syncTimeout=3000", _ => $"https://nuget.pkg.github.com/MyOrganisation{i % 3}/index.json?api-version=6.0", @@ -590,10 +617,10 @@ static string PerfHugeValue(int i) for (var line = 0; line < 60; line++) sb.AppendLine($"{b64.Substring((i + line) % 4, 60)}{i * 31337 + line * 7:D8}=="); sb.AppendLine("-----END CERTIFICATE-----"); - sb.AppendLine("-----BEGIN PRIVATE KEY-----"); + sb.AppendLine("-----BEGIN FAKE TEST KEY-----"); for (var line = 0; line < 52; line++) sb.AppendLine($"{b64.Substring((i + line + 2) % 4, 60)}{i * 99991 + line * 13:D8}Ag=="); - sb.AppendLine("-----END PRIVATE KEY-----"); + sb.AppendLine("-----END FAKE TEST KEY-----"); sb.AppendLine("-----BEGIN CERTIFICATE-----"); for (var line = 0; line < 42; line++) sb.AppendLine($"{b64.Substring((i + line + 1) % 4, 60)}{i * 65537 + line * 11:D8}=="); From 44274df2387198a7d53212dbaa95106b61ee1f1d Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Mon, 23 Feb 2026 16:42:20 +1030 Subject: [PATCH 3/9] Only load octopus_parameters when it's used in a script, use mapfile to read. --- .../Scripting/Bash/BashScriptBootstrapper.cs | 51 +++++++++++-- .../Scripting/Bash/BashScriptExecutor.cs | 2 +- .../Features/Scripting/Bash/Bootstrap.sh | 74 ++++++------------- .../Fixtures/Bash/BashFixture.cs | 40 +++++++++- .../Bash/Scripts/no-octopus-parameters.sh | 8 ++ 5 files changed, 112 insertions(+), 63 deletions(-) create mode 100644 source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh diff --git a/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs b/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs index e3b977b47e..9238bc51a9 100644 --- a/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs +++ b/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -37,22 +37,32 @@ public static string FormatCommandArguments(string bootstrapFile) return commandArguments.ToString(); } - public static string PrepareConfigurationFile(string workingDirectory, IVariables variables) + public static string PrepareConfigurationFile(string workingDirectory, IVariables variables, Script script) { var configurationFile = Path.Combine(workingDirectory, "Configure." + Guid.NewGuid().ToString().Substring(10) + ".sh"); var builder = new StringBuilder(BootstrapScriptTemplate); - var encryptedVariables = EncryptVariables(variables); - var variableString = GetEncryptedVariablesKvp(variables); + var featureEnabled = FeatureToggle.BashParametersArrayFeatureToggle.IsEnabled(variables); + builder.Replace("#### BashParametersArrayFeatureToggle ####", featureEnabled ? "true" : "false"); + var encryptedVariables = EncryptVariables(variables); builder.Replace("#### VariableDeclarations ####", string.Join(LinuxNewLine, GetVariableSwitchConditions(encryptedVariables))); - builder.Replace("#### BashParametersArrayFeatureToggle ####", FeatureToggle.BashParametersArrayFeatureToggle.IsEnabled(variables) ? "true" : "false"); - if (FeatureToggle.BashParametersArrayFeatureToggle.IsEnabled(variables)) + if (featureEnabled) { + var variableString = GetEncryptedVariablesKvp(variables); builder.Replace("#### VARIABLESTRING.IV ####", variableString.iv); builder.Replace("#### VARIABLESTRING.ENCRYPTED ####", variableString.encrypted); + + // Check if the user script or any script modules use octopus_parameters + var scriptUsesOctopusParameters = ScriptUsesOctopusParameters(script, variables); + builder.Replace("#### SCRIPT_USES_OCTOPUS_PARAMETERS ####", scriptUsesOctopusParameters ? "true" : "false"); + } + else + { + // Feature toggle is off, so the placeholder won't be used but still needs to be replaced + builder.Replace("#### SCRIPT_USES_OCTOPUS_PARAMETERS ####", "false"); } using (var file = new FileStream(configurationFile, FileMode.CreateNew, FileAccess.Write)) @@ -66,6 +76,31 @@ public static string PrepareConfigurationFile(string workingDirectory, IVariable return configurationFile; } + static bool ScriptUsesOctopusParameters(Script script, IVariables variables) + { + // Check if script actually uses octopus_parameters array access + // Pattern matches: octopus_parameters[key], octopus_parameters[@], etc. + const string pattern = @"octopus_parameters\["; + var regex = new System.Text.RegularExpressions.Regex(pattern); + + // Check the main user script + if (File.Exists(script.File) && regex.IsMatch(File.ReadAllText(script.File))) + return true; + + // Check any bash script modules that will be sourced + foreach (var variableName in variables.GetNames().Where(ScriptVariables.IsLibraryScriptModule)) + { + if (ScriptVariables.GetLibraryScriptModuleLanguage(variables, variableName) == ScriptSyntax.Bash) + { + var contents = variables.Get(variableName); + if (contents != null && regex.IsMatch(contents)) + return true; + } + } + + return false; + } + static (string encrypted, string iv) GetEncryptedVariablesKvp(IVariables variables) { var sb = new StringBuilder(); @@ -73,8 +108,8 @@ public static string PrepareConfigurationFile(string workingDirectory, IVariable .Where(v => !ScriptVariables.IsLibraryScriptModule(v.Key)) .Where(v => !ScriptVariables.IsBuildInformationVariable(v.Key))) { - var value = variable.Value ?? "nul"; - sb.Append(EncodeValue(variable.Key)).Append("$").AppendLine(EncodeValue(value)); + sb.Append(variable.Key).Append('\0'); + sb.Append(variable.Value ?? "").Append('\0'); } var encrypted = VariableEncryptor.Encrypt(sb.ToString()); diff --git a/source/Calamari.Common/Features/Scripting/Bash/BashScriptExecutor.cs b/source/Calamari.Common/Features/Scripting/Bash/BashScriptExecutor.cs index 4cff762886..4d69a1480f 100644 --- a/source/Calamari.Common/Features/Scripting/Bash/BashScriptExecutor.cs +++ b/source/Calamari.Common/Features/Scripting/Bash/BashScriptExecutor.cs @@ -19,7 +19,7 @@ protected override IEnumerable PrepareExecution(Script script, Dictionary? environmentVars = null) { var workingDirectory = Path.GetDirectoryName(script.File); - var configurationFile = BashScriptBootstrapper.PrepareConfigurationFile(workingDirectory, variables); + var configurationFile = BashScriptBootstrapper.PrepareConfigurationFile(workingDirectory, variables, script); var (bootstrapFile, otherTemporaryFiles) = BashScriptBootstrapper.PrepareBootstrapFile(script, configurationFile, workingDirectory, variables); var invocation = new CommandLineInvocation( diff --git a/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh b/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh index 40dc195b26..c85e0eb5f1 100644 --- a/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh +++ b/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh @@ -264,57 +264,27 @@ function decrypt_and_parse_variables { local encrypted="$1" local iv="$2" - local decrypted - decrypted=$(decrypt_variable "$encrypted" "$iv") declare -gA octopus_parameters=() - local -a b64_keys=() - local -a b64_values=() - local -a key_byte_lengths=() - local -a value_byte_lengths=() - - local b64_key b64_value key_pad val_pad - while IFS='$' read -r b64_key b64_value; do - b64_value="${b64_value//$'\n'/}" - [[ -z "$b64_key" ]] && continue - - if [[ "$b64_key" == *"==" ]]; then key_pad=2 - elif [[ "$b64_key" == *"=" ]]; then key_pad=1 - else key_pad=0; fi - - if [[ "$b64_value" == *"==" ]]; then val_pad=2 - elif [[ "$b64_value" == *"=" ]]; then val_pad=1 - else val_pad=0; fi - - b64_keys+=("$b64_key") - b64_values+=("$b64_value") - key_byte_lengths+=($(( ${#b64_key} / 4 * 3 - key_pad ))) - value_byte_lengths+=($(( ${#b64_value} / 4 * 3 - val_pad ))) - done <<< "$decrypted" - - [[ ${#b64_keys[@]} -eq 0 ]] && return - - local keys_tmp values_tmp - keys_tmp=$(mktemp) || { echo "Failed to create temp file for octopus_parameters" >&2; return 1; } - values_tmp=$(mktemp) || { rm -f "$keys_tmp"; echo "Failed to create temp file for octopus_parameters" >&2; return 1; } - trap 'rm -f "$keys_tmp" "$values_tmp"' RETURN - - printf '%s\n' "${b64_keys[@]}" | base64 -d > "$keys_tmp" || { echo "base64 decode failed for octopus_parameters keys" >&2; return 1; } - printf '%s\n' "${b64_values[@]}" | base64 -d > "$values_tmp" || { echo "base64 decode failed for octopus_parameters values" >&2; return 1; } - - exec 3< "$keys_tmp" - exec 4< "$values_tmp" - - local idx decoded_key decoded_value - for idx in "${!b64_keys[@]}"; do - LC_ALL=C read -r -N "${key_byte_lengths[idx]}" decoded_key <&3 - LC_ALL=C read -r -N "${value_byte_lengths[idx]}" decoded_value <&4 - [[ "$decoded_value" == "nul" ]] && decoded_value="" - [[ -n "$decoded_key" ]] && octopus_parameters["$decoded_key"]="$decoded_value" + # Decrypt the inline base64-encoded ciphertext and read NUL-delimited + # key\0value\0 pairs via mapfile in one shot. + local -a _kv + mapfile -d "" _kv < <(echo "$encrypted" | openssl enc -aes-256-cbc -d -a -A -nosalt -K "$sensitiveVariableKey" -iv "$iv") + + local i + for (( i = 0; i < ${#_kv[@]}; i += 2 )); do + [[ -n "${_kv[i]}" ]] && octopus_parameters["${_kv[i]}"]="${_kv[i+1]:-}" done +} - exec 3<&- - exec 4<&- +function _ensure_octopus_parameters_loaded { + # Load octopus_parameters if not already loaded and feature toggle is on + if [[ "${_octopus_parameters_loaded:-false}" != "true" ]] && [[ "$bashParametersArrayFeatureToggle" == "true" ]]; then + if (( ${BASH_VERSINFO[0]:-0} > 4 || (${BASH_VERSINFO[0]:-0} == 4 && ${BASH_VERSINFO[1]:-0} > 2) )); then + decrypt_and_parse_variables "#### VARIABLESTRING.ENCRYPTED ####" "#### VARIABLESTRING.IV ####" + fi + _octopus_parameters_loaded=true + fi } # ----------------------------------------------------------------------------- @@ -382,11 +352,9 @@ function report_kubernetes_manifest_file } bashParametersArrayFeatureToggle=#### BashParametersArrayFeatureToggle #### +scriptUsesOctopusParameters=#### SCRIPT_USES_OCTOPUS_PARAMETERS #### -if [ "$bashParametersArrayFeatureToggle" = true ]; then - if (( ${BASH_VERSINFO[0]:-0} > 4 || (${BASH_VERSINFO[0]:-0} == 4 && ${BASH_VERSINFO[1]:-0} > 2) )); then - decrypt_and_parse_variables "#### VARIABLESTRING.ENCRYPTED ####" "#### VARIABLESTRING.IV ####" - else - echo "Bash version 4.2 or later is required to use octopus_parameters" - fi +# Eagerly load octopus_parameters only if the user script actually uses it +if [ "$scriptUsesOctopusParameters" = true ]; then + _ensure_octopus_parameters_loaded fi diff --git a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs index 2d75d603f8..dd3723e1ed 100644 --- a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs +++ b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs @@ -423,7 +423,6 @@ public void ShouldBeAbleToEnumerateVariableValues(FeatureToggle? featureToggle) output.AssertOutput($"Key: CombiningDiacritical Caf\u0301, Value: Caf\u00e9"); } - [Explicit] [Category("Performance")] [TestCase( 100, 30_000, Description = "100 variables (~small deployment)")] [TestCase( 500, 60_000, Description = "500 variables (~medium deployment)")] @@ -489,6 +488,45 @@ public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int t $"Loading {variableCount} variables should complete within {timeLimitMs / 1000.0:F0}s"); } + [Category("Performance")] + [TestCase(20_000, Description = "20 000 variables but array not loaded")] + [RequiresBashDotExeIfOnWindows] + public void ShouldNotLoadOctopusParametersWhenNotUsed(int variableCount) + { + // This test verifies that when a script doesn't use octopus_parameters, + // the array is NOT loaded, avoiding the ~6-7s overhead for large variable sets. + var perfVariables = BuildPerformanceTestVariables(variableCount); + var variables = new Dictionary(perfVariables) + .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); + + var stopwatch = Stopwatch.StartNew(); + var (output, _) = RunScript("no-octopus-parameters.sh", variables); + stopwatch.Stop(); + + var elapsedMs = stopwatch.ElapsedMilliseconds; + + // Also measure how long it takes WITH the array loaded for comparison + var stopwatch2 = Stopwatch.StartNew(); + var (output2, _) = RunScript("count-octopus-parameters.sh", variables); + stopwatch2.Stop(); + var elapsedWithArrayMs = stopwatch2.ElapsedMilliseconds; + var savedMs = elapsedWithArrayMs - elapsedMs; + + TestContext.WriteLine(""); + TestContext.WriteLine($"Variables: {variableCount:N0}"); + TestContext.WriteLine($"Without array: {elapsedMs} ms"); + TestContext.WriteLine($"With array: {elapsedWithArrayMs} ms"); + TestContext.WriteLine($"Time saved: {savedMs} ms"); + + output.AssertSuccess(); + output.AssertOutput("ScriptRan=true"); + output.AssertOutput("SentinelValue=PerfSentinelValue"); + + savedMs.Should().BeGreaterThan( + 5000, + $"Not loading octopus_parameters should save more than 5 seconds with {variableCount:N0} variables"); + } + // --------------------------------------------------------------------------- // Realistic variable generator for performance tests. // diff --git a/source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh b/source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh new file mode 100644 index 0000000000..b05a77b5c6 --- /dev/null +++ b/source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Script that deliberately does NOT use octopus_parameters +# Used to test that the array is not loaded when not needed + +# Use get_octopusvariable instead (uses the switch statement, not the array) +name=$(get_octopusvariable "PerfSentinel") +echo "ScriptRan=true" +echo "SentinelValue=$name" From 1cff0422aeb98c8c3eef9f3c4b78e3b48fd2d21f Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Tue, 24 Feb 2026 09:52:49 +1030 Subject: [PATCH 4/9] Don't replace variables strings when octopus_parameters is not used in script --- global.json | 2 +- .../Scripting/Bash/BashScriptBootstrapper.cs | 21 +++-- .../Fixtures/Bash/BashFixture.cs | 92 +++++++++++++------ 3 files changed, 78 insertions(+), 37 deletions(-) diff --git a/global.json b/global.json index f023fd12ae..fd5c91310e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.415", + "version": "8.0.407", "rollForward": "latestFeature", "allowPrerelease": false } diff --git a/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs b/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs index 9238bc51a9..fe1e87cb17 100644 --- a/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs +++ b/source/Calamari.Common/Features/Scripting/Bash/BashScriptBootstrapper.cs @@ -48,16 +48,21 @@ public static string PrepareConfigurationFile(string workingDirectory, IVariable var encryptedVariables = EncryptVariables(variables); builder.Replace("#### VariableDeclarations ####", string.Join(LinuxNewLine, GetVariableSwitchConditions(encryptedVariables))); - if (featureEnabled) { - var variableString = GetEncryptedVariablesKvp(variables); - builder.Replace("#### VARIABLESTRING.IV ####", variableString.iv); - builder.Replace("#### VARIABLESTRING.ENCRYPTED ####", variableString.encrypted); - - // Check if the user script or any script modules use octopus_parameters var scriptUsesOctopusParameters = ScriptUsesOctopusParameters(script, variables); - builder.Replace("#### SCRIPT_USES_OCTOPUS_PARAMETERS ####", scriptUsesOctopusParameters ? "true" : "false"); + // If the script doesn't use octopus_parameters at all, we don't want to bloat the bootstrap script with unused values. + if (scriptUsesOctopusParameters) + { + var variableString = GetEncryptedVariablesKvp(variables); + builder.Replace("#### VARIABLESTRING.IV ####", variableString.iv); + builder.Replace("#### VARIABLESTRING.ENCRYPTED ####", variableString.encrypted); + builder.Replace("#### SCRIPT_USES_OCTOPUS_PARAMETERS ####", "true"); + } + else + { + builder.Replace("#### SCRIPT_USES_OCTOPUS_PARAMETERS ####", "false"); + } } else { @@ -83,11 +88,9 @@ static bool ScriptUsesOctopusParameters(Script script, IVariables variables) const string pattern = @"octopus_parameters\["; var regex = new System.Text.RegularExpressions.Regex(pattern); - // Check the main user script if (File.Exists(script.File) && regex.IsMatch(File.ReadAllText(script.File))) return true; - // Check any bash script modules that will be sourced foreach (var variableName in variables.GetNames().Where(ScriptVariables.IsLibraryScriptModule)) { if (ScriptVariables.GetLibraryScriptModuleLanguage(variables, variableName) == ScriptSyntax.Bash) diff --git a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs index dd3723e1ed..ed31e42eaa 100644 --- a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs +++ b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs @@ -6,6 +6,8 @@ using System.Text; using System.Text.RegularExpressions; using Calamari.Common.Features.Processes; +using Calamari.Common.Features.Scripting; +using Calamari.Common.Features.Scripting.Bash; using Calamari.Common.FeatureToggles; using Calamari.Common.Plumbing; using Calamari.Common.Plumbing.Variables; @@ -494,37 +496,73 @@ public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int t public void ShouldNotLoadOctopusParametersWhenNotUsed(int variableCount) { // This test verifies that when a script doesn't use octopus_parameters, - // the array is NOT loaded, avoiding the ~6-7s overhead for large variable sets. + // the encrypted variable string markers are still present in the configuration file + // (meaning the data is available for get_octopusvariable), but the array + // is NOT loaded, avoiding the ~6-7s overhead for large variable sets. var perfVariables = BuildPerformanceTestVariables(variableCount); var variables = new Dictionary(perfVariables) .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); - var stopwatch = Stopwatch.StartNew(); - var (output, _) = RunScript("no-octopus-parameters.sh", variables); - stopwatch.Stop(); - - var elapsedMs = stopwatch.ElapsedMilliseconds; - - // Also measure how long it takes WITH the array loaded for comparison - var stopwatch2 = Stopwatch.StartNew(); - var (output2, _) = RunScript("count-octopus-parameters.sh", variables); - stopwatch2.Stop(); - var elapsedWithArrayMs = stopwatch2.ElapsedMilliseconds; - var savedMs = elapsedWithArrayMs - elapsedMs; - - TestContext.WriteLine(""); - TestContext.WriteLine($"Variables: {variableCount:N0}"); - TestContext.WriteLine($"Without array: {elapsedMs} ms"); - TestContext.WriteLine($"With array: {elapsedWithArrayMs} ms"); - TestContext.WriteLine($"Time saved: {savedMs} ms"); - - output.AssertSuccess(); - output.AssertOutput("ScriptRan=true"); - output.AssertOutput("SentinelValue=PerfSentinelValue"); - - savedMs.Should().BeGreaterThan( - 5000, - $"Not loading octopus_parameters should save more than 5 seconds with {variableCount:N0} variables"); + var scriptFile = GetFixtureResource("Scripts", "no-octopus-parameters.sh"); + var workingDirectory = Path.GetDirectoryName(scriptFile); + + // Create the configuration file to inspect it + var script = new Script(scriptFile); + var calamariVariables = new CalamariVariables(); + foreach (var kvp in variables) + calamariVariables.Set(kvp.Key, kvp.Value); + + var configFile = BashScriptBootstrapper.PrepareConfigurationFile(workingDirectory, + calamariVariables, script); + + try + { + // Read the configuration file content + var configContent = File.ReadAllText(configFile); + + // Verify the encrypted variable string markers ARE still present + // (because script doesn't use octopus_parameters, the KVP data is NOT included, + // so the markers should remain unreplaced in the template) + configContent.Should().Contain("#### VARIABLESTRING.IV ####", + "The IV marker should remain unreplaced since the script doesn't use octopus_parameters"); + configContent.Should().Contain("#### VARIABLESTRING.ENCRYPTED ####", + "The encrypted marker should remain unreplaced since the script doesn't use octopus_parameters"); + configContent.Should().Contain("scriptUsesOctopusParameters=false", + "The script should be marked as NOT using octopus_parameters"); + + // Now run the script and verify timing + var stopwatch = Stopwatch.StartNew(); + var (output, _) = RunScript("no-octopus-parameters.sh", variables); + stopwatch.Stop(); + var elapsedMs = stopwatch.ElapsedMilliseconds; + + // Also measure how long it takes WITH the array loaded for comparison + var stopwatch2 = Stopwatch.StartNew(); + var (output2, _) = RunScript("count-octopus-parameters.sh", variables); + stopwatch2.Stop(); + var elapsedWithArrayMs = stopwatch2.ElapsedMilliseconds; + var savedMs = elapsedWithArrayMs - elapsedMs; + + TestContext.WriteLine(""); + TestContext.WriteLine($"Variables: {variableCount:N0}"); + TestContext.WriteLine($"Config file size: {new FileInfo(configFile).Length / 1024.0:F1} KB"); + TestContext.WriteLine($"Without array: {elapsedMs} ms"); + TestContext.WriteLine($"With array: {elapsedWithArrayMs} ms"); + TestContext.WriteLine($"Time saved: {savedMs} ms"); + + output.AssertSuccess(); + output.AssertOutput("ScriptRan=true"); + output.AssertOutput("SentinelValue=PerfSentinelValue"); + + savedMs.Should().BeGreaterThan( + 5000, + $"Not loading octopus_parameters should save more than 5 seconds with {variableCount:N0} variables"); + } + finally + { + if (File.Exists(configFile)) + File.Delete(configFile); + } } // --------------------------------------------------------------------------- From b8933c38e0b692ec1d6b0c06974828c163d804ce Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Tue, 24 Feb 2026 09:53:58 +1030 Subject: [PATCH 5/9] Revert global.json changes --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index fd5c91310e..f023fd12ae 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.407", + "version": "8.0.415", "rollForward": "latestFeature", "allowPrerelease": false } From 764d4668372e9277fbdb3fb5672cc38b7ece348c Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Tue, 24 Feb 2026 10:36:20 +1030 Subject: [PATCH 6/9] Tidy-up --- .../Features/Scripting/Bash/Bootstrap.sh | 7 +- .../Fixtures/Bash/BashFixture.cs | 283 ----------------- .../Fixtures/Bash/BashPerformanceFixture.cs | 297 ++++++++++++++++++ 3 files changed, 300 insertions(+), 287 deletions(-) create mode 100644 source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs diff --git a/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh b/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh index c85e0eb5f1..2e1400ac53 100644 --- a/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh +++ b/source/Calamari.Common/Features/Scripting/Bash/Bootstrap.sh @@ -278,12 +278,12 @@ function decrypt_and_parse_variables { } function _ensure_octopus_parameters_loaded { - # Load octopus_parameters if not already loaded and feature toggle is on - if [[ "${_octopus_parameters_loaded:-false}" != "true" ]] && [[ "$bashParametersArrayFeatureToggle" == "true" ]]; then + # Load octopus_parameters if feature toggle is enabled and Bash version supports it + bashParametersArrayFeatureToggle=#### BashParametersArrayFeatureToggle #### + if [[ "$bashParametersArrayFeatureToggle" == "true" ]]; then if (( ${BASH_VERSINFO[0]:-0} > 4 || (${BASH_VERSINFO[0]:-0} == 4 && ${BASH_VERSINFO[1]:-0} > 2) )); then decrypt_and_parse_variables "#### VARIABLESTRING.ENCRYPTED ####" "#### VARIABLESTRING.IV ####" fi - _octopus_parameters_loaded=true fi } @@ -351,7 +351,6 @@ function report_kubernetes_manifest_file report_kubernetes_manifest "$MANIFEST" "$NAMESPACE" } -bashParametersArrayFeatureToggle=#### BashParametersArrayFeatureToggle #### scriptUsesOctopusParameters=#### SCRIPT_USES_OCTOPUS_PARAMETERS #### # Eagerly load octopus_parameters only if the user script actually uses it diff --git a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs index ed31e42eaa..36ff193d8f 100644 --- a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs +++ b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs @@ -1,13 +1,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using Calamari.Common.Features.Processes; -using Calamari.Common.Features.Scripting; -using Calamari.Common.Features.Scripting.Bash; using Calamari.Common.FeatureToggles; using Calamari.Common.Plumbing; using Calamari.Common.Plumbing.Variables; @@ -424,285 +420,6 @@ public void ShouldBeAbleToEnumerateVariableValues(FeatureToggle? featureToggle) output.AssertOutput($"Key: SupplementaryPlane 𝒜𝄞, Value: value 𝐀𝁆"); output.AssertOutput($"Key: CombiningDiacritical Caf\u0301, Value: Caf\u00e9"); } - - [Category("Performance")] - [TestCase( 100, 30_000, Description = "100 variables (~small deployment)")] - [TestCase( 500, 60_000, Description = "500 variables (~medium deployment)")] - [TestCase(1_000, 120_000, Description = "1 000 variables (~large deployment)")] - [TestCase(5_000, 300_000, Description = "5 000 variables (~stress test)")] - [TestCase(20_000, 300_000, Description = "5 000 variables (~stress test)")] - [RequiresBashDotExeIfOnWindows] - public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int timeLimitMs) - { - // Build the realistic payload separately so we can measure its size - // before it gets encrypted and written into the bootstrap script. - var perfVariables = BuildPerformanceTestVariables(variableCount); - var variables = new Dictionary(perfVariables) - .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); - - var keyBytes = perfVariables.Keys .Select(k => (long)Encoding.UTF8.GetByteCount(k)) .ToArray(); - var valueBytes = perfVariables.Values.Select(v => (long)Encoding.UTF8.GetByteCount(v ?? "")).ToArray(); - var totalKeyBytes = keyBytes.Sum(); - var totalValueBytes = valueBytes.Sum(); - var totalPairBytes = totalKeyBytes + totalValueBytes; - - var stopwatch = Stopwatch.StartNew(); - var (output, _) = RunScript("count-octopus-parameters.sh", variables); - stopwatch.Stop(); - - var elapsedMs = stopwatch.ElapsedMilliseconds; - - // ── Structured performance report ──────────────────────────────────── - static string Kb(long b) => $"{b / 1024.0,7:F1} KB"; - static string Fmt(double b) => b >= 1024 ? $"{b / 1024.0:F1} KB" : $"{b:F0} B"; - - TestContext.WriteLine(""); - TestContext.WriteLine("── Payload ─────────────────────────────────────────────────────"); - TestContext.WriteLine($" Variables : {perfVariables.Count,6:N0}"); - TestContext.WriteLine($" Keys : avg {Fmt(keyBytes.Average()),9} │ min {keyBytes.Min(),5} B │ max {keyBytes.Max(),7} B │ total {Kb(totalKeyBytes)}"); - TestContext.WriteLine($" Values : avg {Fmt(valueBytes.Average()),9} │ min {valueBytes.Min(),5} B │ max {valueBytes.Max(),7} B │ total {Kb(totalValueBytes)}"); - TestContext.WriteLine($" Pairs : avg {Fmt(keyBytes.Average() + valueBytes.Average()),9} │ │ total {Kb(totalPairBytes)}"); - TestContext.WriteLine("── Timing ──────────────────────────────────────────────────────"); - TestContext.WriteLine($" Total : {elapsedMs,7} ms (limit {timeLimitMs / 1000} s)"); - TestContext.WriteLine($" Per var : {elapsedMs / (double)perfVariables.Count,9:F2} ms"); - TestContext.WriteLine($" Throughput : {totalPairBytes / 1024.0 / (elapsedMs / 1000.0),7:F1} KB/s"); - TestContext.WriteLine("────────────────────────────────────────────────────────────────"); - - output.AssertSuccess(); - - var fullOutput = string.Join(Environment.NewLine, output.CapturedOutput.Infos); - if (fullOutput.Contains("Bash version 4.2 or later is required to use octopus_parameters")) - { - Assert.Ignore("Bash 4.2+ required for octopus_parameters; performance assertion skipped."); - return; - } - - var countLine = output.GetOutputForLineContaining("VariableCount="); - var loadedCount = int.Parse(Regex.Match(countLine, @"VariableCount=(\d+)").Groups[1].Value); - loadedCount.Should().BeGreaterThanOrEqualTo( - variableCount, - $"octopus_parameters should contain at least the {variableCount} variables we passed in"); - - output.AssertOutput("SpotCheck=PerfSentinelValue"); - - elapsedMs.Should().BeLessThan( - timeLimitMs, - $"Loading {variableCount} variables should complete within {timeLimitMs / 1000.0:F0}s"); - } - - [Category("Performance")] - [TestCase(20_000, Description = "20 000 variables but array not loaded")] - [RequiresBashDotExeIfOnWindows] - public void ShouldNotLoadOctopusParametersWhenNotUsed(int variableCount) - { - // This test verifies that when a script doesn't use octopus_parameters, - // the encrypted variable string markers are still present in the configuration file - // (meaning the data is available for get_octopusvariable), but the array - // is NOT loaded, avoiding the ~6-7s overhead for large variable sets. - var perfVariables = BuildPerformanceTestVariables(variableCount); - var variables = new Dictionary(perfVariables) - .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); - - var scriptFile = GetFixtureResource("Scripts", "no-octopus-parameters.sh"); - var workingDirectory = Path.GetDirectoryName(scriptFile); - - // Create the configuration file to inspect it - var script = new Script(scriptFile); - var calamariVariables = new CalamariVariables(); - foreach (var kvp in variables) - calamariVariables.Set(kvp.Key, kvp.Value); - - var configFile = BashScriptBootstrapper.PrepareConfigurationFile(workingDirectory, - calamariVariables, script); - - try - { - // Read the configuration file content - var configContent = File.ReadAllText(configFile); - - // Verify the encrypted variable string markers ARE still present - // (because script doesn't use octopus_parameters, the KVP data is NOT included, - // so the markers should remain unreplaced in the template) - configContent.Should().Contain("#### VARIABLESTRING.IV ####", - "The IV marker should remain unreplaced since the script doesn't use octopus_parameters"); - configContent.Should().Contain("#### VARIABLESTRING.ENCRYPTED ####", - "The encrypted marker should remain unreplaced since the script doesn't use octopus_parameters"); - configContent.Should().Contain("scriptUsesOctopusParameters=false", - "The script should be marked as NOT using octopus_parameters"); - - // Now run the script and verify timing - var stopwatch = Stopwatch.StartNew(); - var (output, _) = RunScript("no-octopus-parameters.sh", variables); - stopwatch.Stop(); - var elapsedMs = stopwatch.ElapsedMilliseconds; - - // Also measure how long it takes WITH the array loaded for comparison - var stopwatch2 = Stopwatch.StartNew(); - var (output2, _) = RunScript("count-octopus-parameters.sh", variables); - stopwatch2.Stop(); - var elapsedWithArrayMs = stopwatch2.ElapsedMilliseconds; - var savedMs = elapsedWithArrayMs - elapsedMs; - - TestContext.WriteLine(""); - TestContext.WriteLine($"Variables: {variableCount:N0}"); - TestContext.WriteLine($"Config file size: {new FileInfo(configFile).Length / 1024.0:F1} KB"); - TestContext.WriteLine($"Without array: {elapsedMs} ms"); - TestContext.WriteLine($"With array: {elapsedWithArrayMs} ms"); - TestContext.WriteLine($"Time saved: {savedMs} ms"); - - output.AssertSuccess(); - output.AssertOutput("ScriptRan=true"); - output.AssertOutput("SentinelValue=PerfSentinelValue"); - - savedMs.Should().BeGreaterThan( - 5000, - $"Not loading octopus_parameters should save more than 5 seconds with {variableCount:N0} variables"); - } - finally - { - if (File.Exists(configFile)) - File.Delete(configFile); - } - } - - // --------------------------------------------------------------------------- - // Realistic variable generator for performance tests. - // - // Produces a distribution that approximates a real Octopus deployment: - // - // Bucket | Share | Name length | Value length | Example - // --------|-------|--------------|----------------|---------------------------- - // Small | 60% | 15–30 chars | 10–40 chars | Project.Name, port numbers - // Medium | 25% | 40–65 chars | 150–300 chars | SQL / Redis connection strings - // Large | 10% | 70–130 chars| 700–950 chars | JSON config blobs - // Huge | 5% | 100–180 chars| 6 000–9 000 chars | PEM certificate + key bundles - // - // All keys are unique (every template appends the loop index). - // A "PerfSentinel" key is added for correctness spot-checks. - // --------------------------------------------------------------------------- - - static Dictionary BuildPerformanceTestVariables(int count) - { - var result = new Dictionary(count + 1); - for (var i = 0; i < count; i++) - { - var (key, value) = (i % 20) switch - { - < 12 => (PerfSmallKey(i), PerfSmallValue(i)), - < 17 => (PerfMediumKey(i), PerfMediumValue(i)), - < 19 => (PerfLargeKey(i), PerfLargeValue(i)), - _ => (PerfHugeKey(i), PerfHugeValue(i)), - }; - result[key] = value; - } - result["PerfSentinel"] = "PerfSentinelValue"; - return result; - } - - // Small (60%): names ~15–30 chars, values ~10–40 chars - static string PerfSmallKey(int i) => (i % 12) switch - { - 0 => $"Project.Name.{i:D4}", - 1 => $"Environment.Region.{i:D4}", - 2 => $"Application.Version.{i:D4}", - 3 => $"Config.Timeout.Seconds.{i:D4}", - 4 => $"Deploy.Mode.{i:D4}", - 5 => $"Service.Port.{i:D4}", - 6 => $"Feature.{i:D4}.Enabled", - 7 => $"Build.Number.{i:D4}", - 8 => $"Cluster.Zone.{i:D4}", - 9 => $"Agent.Pool.{i:D4}", - 10 => $"Release.Channel.{i:D4}", - _ => $"Tag.Value.{i:D4}", - }; - - static string PerfSmallValue(int i) => (i % 12) switch - { - 0 => $"production-{i % 5}", - 1 => $"us-{(i % 2 == 0 ? "east" : "west")}-{i % 3 + 1}", - 2 => $"v{i % 10}.{i % 100 + 1}.{i % 1000}", - 3 => $"{i % 120 + 10}", - 4 => i % 2 == 0 ? "blue-green" : "rolling", - 5 => $"{8080 + i % 100}", - 6 => i % 2 == 0 ? "true" : "false", - 7 => $"build-{i:D6}", - 8 => $"zone-{(char)('a' + i % 3)}", - 9 => $"pool-{i % 4}", - 10 => i % 2 == 0 ? "stable" : "beta", - _ => $"tag-{i % 20:D3}", - }; - - // Medium (25%): names ~40–65 chars, values ~150–300 chars - static string PerfMediumKey(int i) => (i % 5) switch - { - 0 => $"Application.Database.Primary.ConnectionString.{i:D4}", - 1 => $"Azure.Storage.Account{i % 3}.AccessKey.{i:D4}", - 2 => $"Service.Authentication.BearerToken.Endpoint.{i:D4}", - 3 => $"Infrastructure.Cache.Redis{i % 2}.ConnectionString.{i:D4}", - _ => $"Octopus.Action.Package[MyApp.Service{i % 5}].FeedUri.{i:D4}", - }; - - static string PerfMediumValue(int i) => (i % 5) switch - { - 0 => $"Server=tcp:sql{i % 3}.database.windows.net,1433;Initial Catalog=AppDb{i % 10};User Id=svc_app;Password=P@ssw0rd{i:D4}!;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;", - 1 => $"DefaultEndpointsProtocol=https;AccountName=storage{i % 5}acct;AccountKey=FAKE-KEY-NOT-A-SECRET-{i:D4};EndpointSuffix=core.windows.net", - 2 => $"https://login.microsoftonline.com/{i:D8}-aaaa-bbbb-cccc-{i:D12}/oauth2/v2.0/token", - 3 => $"cache{i % 2}.redis.cache.windows.net:6380,password=cacheSecret{i:D4}==,ssl=true,abortConnect=false,connectTimeout=5000,syncTimeout=3000", - _ => $"https://nuget.pkg.github.com/MyOrganisation{i % 3}/index.json?api-version=6.0", - }; - - // Large (10%): names ~70–130 chars, values ~700–950 chars (JSON config blobs) - static string PerfLargeKey(int i) => - $"Octopus.Action[Step {i % 10}: Deploy {(i % 2 == 0 ? "Application" : "Infrastructure")} to {(i % 3 == 0 ? "Production" : "Staging")}].Package[MyCompany.Service{i % 5}].Config.{i:D4}"; - - static string PerfLargeValue(int i) => $@"{{ - ""environment"": ""{(i % 2 == 0 ? "production" : "staging")}"", - ""instanceId"": ""{i:D8}"", - ""serviceEndpoints"": {{ - ""auth"": ""https://auth{i % 3}.internal.company.com/oauth/token"", - ""users"": ""https://users{i % 3}.internal.company.com/api/v2"", - ""events"": ""https://events{i % 3}.internal.company.com/stream"" - }}, - ""database"": {{ - ""primary"": ""Server=tcp:primary{i % 2}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_app;Password=P@ssw0rd{i:D6}!;Encrypt=True;Connection Timeout=30;"", - ""replica"": ""Server=tcp:replica{i % 3}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_ro;Password=R3adOnly{i:D6}!;Encrypt=True;Connection Timeout=30;"" - }}, - ""cache"": {{ - ""primary"": ""cache{i % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"", - ""secondary"": ""cache{(i + 1) % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"" - }}, - ""storage"": {{ - ""accountName"": ""storage{i % 5}acct"", - ""containerName"": ""deployments-{i:D4}"", - ""sasToken"": ""sv=2021-12-02&ss=b&srt=co&sp=rwdlacupiytfx&se=2025-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=fakeSignature{i:D6}=="" - }} -}}"; - - // Huge (5%): names ~100–180 chars, values ~6 000–9 000 chars (PEM cert + private key bundle) - static string PerfHugeKey(int i) => - $"Octopus.Action[Step {i % 5}: Long Running {(i % 2 == 0 ? "Database Migration" : "Certificate Rotation")} Task For {(i % 3 == 0 ? "Production-AUS" : "Production-US")} Environment].Package[MyCompany.{(i % 2 == 0 ? "Infrastructure" : "Security")}.Tooling.v{i % 10}].Config.{i:D4}"; - - static string PerfHugeValue(int i) - { - // Each cert line is 60 chars of base64 + 8-digit serial + "==" + newline ≈ 72 chars. - // 60 + 52 + 42 lines across 3 PEM blocks ≈ 154 lines × 72 chars ≈ 11 KB per variable. - const string b64 = "MIIGXTCCBEWgAwIBAgIJAKnmpBuMNbOBMA0GCSqGSIb3DQEBCwUAMIGaMQswCQYD"; // 64 chars - var sb = new StringBuilder(9000); - sb.AppendLine($"# Certificate bundle — slot {i % 3}, serial {i:D8}"); - sb.AppendLine("-----BEGIN CERTIFICATE-----"); - for (var line = 0; line < 60; line++) - sb.AppendLine($"{b64.Substring((i + line) % 4, 60)}{i * 31337 + line * 7:D8}=="); - sb.AppendLine("-----END CERTIFICATE-----"); - sb.AppendLine("-----BEGIN FAKE TEST KEY-----"); - for (var line = 0; line < 52; line++) - sb.AppendLine($"{b64.Substring((i + line + 2) % 4, 60)}{i * 99991 + line * 13:D8}Ag=="); - sb.AppendLine("-----END FAKE TEST KEY-----"); - sb.AppendLine("-----BEGIN CERTIFICATE-----"); - for (var line = 0; line < 42; line++) - sb.AppendLine($"{b64.Substring((i + line + 1) % 4, 60)}{i * 65537 + line * 11:D8}=="); - sb.AppendLine("-----END CERTIFICATE-----"); - return sb.ToString(); - } } public static class AdditionalVariablesExtensions diff --git a/source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs new file mode 100644 index 0000000000..7781a64ada --- /dev/null +++ b/source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs @@ -0,0 +1,297 @@ +using System.Linq; +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using Calamari.Common.Features.Scripting; +using Calamari.Common.Features.Scripting.Bash; +using Calamari.Common.FeatureToggles; +using Calamari.Common.Plumbing.Variables; +using Calamari.Testing.Requirements; +using Calamari.Tests.Helpers; +using NUnit.Framework; + +namespace Calamari.Tests.Fixtures.Bash +{ + [TestFixture] + public class BashPerformanceFixture : CalamariFixture + { + [Category("Performance")] + [TestCase( 100, 1000, Description = "100 variables (~small deployment)")] + [TestCase( 500, 1200, Description = "500 variables (~medium deployment)")] + [TestCase(1_000, 1500, Description = "1 000 variables (~large deployment)")] + [TestCase(5_000, 4500, Description = "5 000 variables (~stress test)")] + [TestCase(20_000, 14_000, Description = "5 000 variables (~stress test)")] + [RequiresBashDotExeIfOnWindows] + public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int timeLimitMs) + { + // Build the realistic payload separately so we can measure its size + // before it gets encrypted and written into the bootstrap script. + var perfVariables = BuildPerformanceTestVariables(variableCount); + var variables = new Dictionary(perfVariables) + .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); + + var keyBytes = perfVariables.Keys.Select(k => (long)Encoding.UTF8.GetByteCount(k)).ToArray(); + var valueBytes = perfVariables.Values.Select(v => (long)Encoding.UTF8.GetByteCount(v ?? "")).ToArray(); + var totalKeyBytes = keyBytes.Sum(); + var totalValueBytes = valueBytes.Sum(); + var totalPairBytes = totalKeyBytes + totalValueBytes; + + var stopwatch = Stopwatch.StartNew(); + var (output, _) = RunScript("count-octopus-parameters.sh", variables); + stopwatch.Stop(); + + var elapsedMs = stopwatch.ElapsedMilliseconds; + + // ── Structured performance report ──────────────────────────────────── + static string Kb(long b) => $"{b / 1024.0,7:F1} KB"; + static string Fmt(double b) => b >= 1024 ? $"{b / 1024.0:F1} KB" : $"{b:F0} B"; + + TestContext.WriteLine(""); + TestContext.WriteLine("── Payload ─────────────────────────────────────────────────────"); + TestContext.WriteLine($" Variables : {perfVariables.Count,6:N0}"); + TestContext.WriteLine($" Keys : avg {Fmt(keyBytes.Average()),9} │ min {keyBytes.Min(),5} B │ max {keyBytes.Max(),7} B │ total {Kb(totalKeyBytes)}"); + TestContext.WriteLine($" Values : avg {Fmt(valueBytes.Average()),9} │ min {valueBytes.Min(),5} B │ max {valueBytes.Max(),7} B │ total {Kb(totalValueBytes)}"); + TestContext.WriteLine($" Pairs : avg {Fmt(keyBytes.Average() + valueBytes.Average()),9} │ │ total {Kb(totalPairBytes)}"); + TestContext.WriteLine("── Timing ──────────────────────────────────────────────────────"); + TestContext.WriteLine($" Total : {elapsedMs,7} ms (limit {timeLimitMs / 1000} s)"); + TestContext.WriteLine($" Per var : {elapsedMs / (double)perfVariables.Count,9:F2} ms"); + TestContext.WriteLine($" Throughput : {totalPairBytes / 1024.0 / (elapsedMs / 1000.0),7:F1} KB/s"); + TestContext.WriteLine("────────────────────────────────────────────────────────────────"); + + output.AssertSuccess(); + + var fullOutput = string.Join(Environment.NewLine, output.CapturedOutput.Infos); + if (fullOutput.Contains("Bash version 4.2 or later is required to use octopus_parameters")) + { + Assert.Ignore("Bash 4.2+ required for octopus_parameters; performance assertion skipped."); + return; + } + + var countLine = output.GetOutputForLineContaining("VariableCount="); + var loadedCount = int.Parse(Regex.Match(countLine, @"VariableCount=(\d+)").Groups[1].Value); + loadedCount.Should().BeGreaterThanOrEqualTo( + variableCount, + $"octopus_parameters should contain at least the {variableCount} variables we passed in"); + + output.AssertOutput("SpotCheck=PerfSentinelValue"); + + elapsedMs.Should().BeLessThan( + timeLimitMs, + $"Loading {variableCount} variables should complete within {timeLimitMs / 1000.0:F0}s"); + } + + [Category("Performance")] + [TestCase(20_000, Description = "20 000 variables but array not loaded")] + [RequiresBashDotExeIfOnWindows] + public void ShouldNotLoadOctopusParametersWhenNotUsed(int variableCount) + { + // This test verifies that when a script doesn't use octopus_parameters, + // the encrypted variable string markers are still present in the configuration file + // (meaning the data is available for get_octopusvariable), but the array + // is NOT loaded, avoiding the ~6-7s overhead for large variable sets. + var perfVariables = BuildPerformanceTestVariables(variableCount); + var variables = new Dictionary(perfVariables) + .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); + + var scriptFile = GetFixtureResource("Scripts", "no-octopus-parameters.sh"); + var workingDirectory = Path.GetDirectoryName(scriptFile); + + // Create the configuration file to inspect it + var script = new Script(scriptFile); + var calamariVariables = new CalamariVariables(); + foreach (var kvp in variables) + calamariVariables.Set(kvp.Key, kvp.Value); + + var configFile = BashScriptBootstrapper.PrepareConfigurationFile(workingDirectory, + calamariVariables, script); + + try + { + // Read the configuration file content + var configContent = File.ReadAllText(configFile); + + // Verify the encrypted variable string markers ARE still present + // (because script doesn't use octopus_parameters, the KVP data is NOT included, + // so the markers should remain unreplaced in the template) + configContent.Should().Contain("#### VARIABLESTRING.IV ####", + "The IV marker should remain unreplaced since the script doesn't use octopus_parameters"); + configContent.Should().Contain("#### VARIABLESTRING.ENCRYPTED ####", + "The encrypted marker should remain unreplaced since the script doesn't use octopus_parameters"); + configContent.Should().Contain("scriptUsesOctopusParameters=false", + "The script should be marked as NOT using octopus_parameters"); + + // Now run the script and verify timing + var stopwatch = Stopwatch.StartNew(); + var (output, _) = RunScript("no-octopus-parameters.sh", variables); + stopwatch.Stop(); + var elapsedMs = stopwatch.ElapsedMilliseconds; + + // Also measure how long it takes WITH the array loaded for comparison + var stopwatch2 = Stopwatch.StartNew(); + var (output2, _) = RunScript("count-octopus-parameters.sh", variables); + stopwatch2.Stop(); + var elapsedWithArrayMs = stopwatch2.ElapsedMilliseconds; + var savedMs = elapsedWithArrayMs - elapsedMs; + + TestContext.WriteLine(""); + TestContext.WriteLine($"Variables: {variableCount:N0}"); + TestContext.WriteLine($"Config file size: {new FileInfo(configFile).Length / 1024.0:F1} KB"); + TestContext.WriteLine($"Without array: {elapsedMs} ms"); + TestContext.WriteLine($"With array: {elapsedWithArrayMs} ms"); + TestContext.WriteLine($"Time saved: {savedMs} ms"); + + output.AssertSuccess(); + output.AssertOutput("ScriptRan=true"); + output.AssertOutput("SentinelValue=PerfSentinelValue"); + } + finally + { + if (File.Exists(configFile)) + File.Delete(configFile); + } + } + + // --------------------------------------------------------------------------- + // Realistic variable generator for performance tests. + // + // Produces a distribution that approximates a real Octopus deployment: + // + // Bucket | Share | Name length | Value length | Example + // --------|-------|--------------|----------------|---------------------------- + // Small | 60% | 15–30 chars | 10–40 chars | Project.Name, port numbers + // Medium | 25% | 40–65 chars | 150–300 chars | SQL / Redis connection strings + // Large | 10% | 70–130 chars| 700–950 chars | JSON config blobs + // Huge | 5% | 100–180 chars| 6 000–9 000 chars | PEM certificate + key bundles + // + // All keys are unique (every template appends the loop index). + // A "PerfSentinel" key is added for correctness spot-checks. + // --------------------------------------------------------------------------- + + static Dictionary BuildPerformanceTestVariables(int count) + { + var result = new Dictionary(count + 1); + for (var i = 0; i < count; i++) + { + var (key, value) = (i % 20) switch + { + < 12 => (PerfSmallKey(i), PerfSmallValue(i)), + < 17 => (PerfMediumKey(i), PerfMediumValue(i)), + < 19 => (PerfLargeKey(i), PerfLargeValue(i)), + _ => (PerfHugeKey(i), PerfHugeValue(i)), + }; + result[key] = value; + } + result["PerfSentinel"] = "PerfSentinelValue"; + return result; + } + + // Small (60%): names ~15–30 chars, values ~10–40 chars + static string PerfSmallKey(int i) => (i % 12) switch + { + 0 => $"Project.Name.{i:D4}", + 1 => $"Environment.Region.{i:D4}", + 2 => $"Application.Version.{i:D4}", + 3 => $"Config.Timeout.Seconds.{i:D4}", + 4 => $"Deploy.Mode.{i:D4}", + 5 => $"Service.Port.{i:D4}", + 6 => $"Feature.{i:D4}.Enabled", + 7 => $"Build.Number.{i:D4}", + 8 => $"Cluster.Zone.{i:D4}", + 9 => $"Agent.Pool.{i:D4}", + 10 => $"Release.Channel.{i:D4}", + _ => $"Tag.Value.{i:D4}", + }; + + static string PerfSmallValue(int i) => (i % 12) switch + { + 0 => $"production-{i % 5}", + 1 => $"us-{(i % 2 == 0 ? "east" : "west")}-{i % 3 + 1}", + 2 => $"v{i % 10}.{i % 100 + 1}.{i % 1000}", + 3 => $"{i % 120 + 10}", + 4 => i % 2 == 0 ? "blue-green" : "rolling", + 5 => $"{8080 + i % 100}", + 6 => i % 2 == 0 ? "true" : "false", + 7 => $"build-{i:D6}", + 8 => $"zone-{(char)('a' + i % 3)}", + 9 => $"pool-{i % 4}", + 10 => i % 2 == 0 ? "stable" : "beta", + _ => $"tag-{i % 20:D3}", + }; + + // Medium (25%): names ~40–65 chars, values ~150–300 chars + static string PerfMediumKey(int i) => (i % 5) switch + { + 0 => $"Application.Database.Primary.ConnectionString.{i:D4}", + 1 => $"Azure.Storage.Account{i % 3}.AccessKey.{i:D4}", + 2 => $"Service.Authentication.BearerToken.Endpoint.{i:D4}", + 3 => $"Infrastructure.Cache.Redis{i % 2}.ConnectionString.{i:D4}", + _ => $"Octopus.Action.Package[MyApp.Service{i % 5}].FeedUri.{i:D4}", + }; + + static string PerfMediumValue(int i) => (i % 5) switch + { + 0 => $"Server=tcp:sql{i % 3}.database.windows.net,1433;Initial Catalog=AppDb{i % 10};User Id=svc_app;Password=P@ssw0rd{i:D4}!;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;", + 1 => $"DefaultEndpointsProtocol=https;AccountName=storage{i % 5}acct;AccountKey=FAKE-KEY-NOT-A-SECRET-{i:D4};EndpointSuffix=core.windows.net", + 2 => $"https://login.microsoftonline.com/{i:D8}-aaaa-bbbb-cccc-{i:D12}/oauth2/v2.0/token", + 3 => $"cache{i % 2}.redis.cache.windows.net:6380,password=cacheSecret{i:D4}==,ssl=true,abortConnect=false,connectTimeout=5000,syncTimeout=3000", + _ => $"https://nuget.pkg.github.com/MyOrganisation{i % 3}/index.json?api-version=6.0", + }; + + // Large (10%): names ~70–130 chars, values ~700–950 chars (JSON config blobs) + static string PerfLargeKey(int i) => + $"Octopus.Action[Step {i % 10}: Deploy {(i % 2 == 0 ? "Application" : "Infrastructure")} to {(i % 3 == 0 ? "Production" : "Staging")}].Package[MyCompany.Service{i % 5}].Config.{i:D4}"; + + static string PerfLargeValue(int i) => $@"{{ + ""environment"": ""{(i % 2 == 0 ? "production" : "staging")}"", + ""instanceId"": ""{i:D8}"", + ""serviceEndpoints"": {{ + ""auth"": ""https://auth{i % 3}.internal.company.com/oauth/token"", + ""users"": ""https://users{i % 3}.internal.company.com/api/v2"", + ""events"": ""https://events{i % 3}.internal.company.com/stream"" + }}, + ""database"": {{ + ""primary"": ""Server=tcp:primary{i % 2}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_app;Password=P@ssw0rd{i:D6}!;Encrypt=True;Connection Timeout=30;"", + ""replica"": ""Server=tcp:replica{i % 3}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_ro;Password=R3adOnly{i:D6}!;Encrypt=True;Connection Timeout=30;"" + }}, + ""cache"": {{ + ""primary"": ""cache{i % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"", + ""secondary"": ""cache{(i + 1) % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"" + }}, + ""storage"": {{ + ""accountName"": ""storage{i % 5}acct"", + ""containerName"": ""deployments-{i:D4}"", + ""sasToken"": ""sv=2021-12-02&ss=b&srt=co&sp=rwdlacupiytfx&se=2025-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=fakeSignature{i:D6}=="" + }} +}}"; + + // Huge (5%): names ~100–180 chars, values ~6 000–9 000 chars (PEM cert + private key bundle) + static string PerfHugeKey(int i) => + $"Octopus.Action[Step {i % 5}: Long Running {(i % 2 == 0 ? "Database Migration" : "Certificate Rotation")} Task For {(i % 3 == 0 ? "Production-AUS" : "Production-US")} Environment].Package[MyCompany.{(i % 2 == 0 ? "Infrastructure" : "Security")}.Tooling.v{i % 10}].Config.{i:D4}"; + + static string PerfHugeValue(int i) + { + // Each cert line is 60 chars of base64 + 8-digit serial + "==" + newline ≈ 72 chars. + // 60 + 52 + 42 lines across 3 PEM blocks ≈ 154 lines × 72 chars ≈ 11 KB per variable. + const string b64 = "MIIGXTCCBEWgAwIBAgIJAKnmpBuMNbOBMA0GCSqGSIb3DQEBCwUAMIGaMQswCQYD"; // 64 chars + var sb = new StringBuilder(9000); + sb.AppendLine($"# Certificate bundle — slot {i % 3}, serial {i:D8}"); + sb.AppendLine("-----BEGIN CERTIFICATE-----"); + for (var line = 0; line < 60; line++) + sb.AppendLine($"{b64.Substring((i + line) % 4, 60)}{i * 31337 + line * 7:D8}=="); + sb.AppendLine("-----END CERTIFICATE-----"); + sb.AppendLine("-----BEGIN FAKE TEST KEY-----"); + for (var line = 0; line < 52; line++) + sb.AppendLine($"{b64.Substring((i + line + 2) % 4, 60)}{i * 99991 + line * 13:D8}Ag=="); + sb.AppendLine("-----END FAKE TEST KEY-----"); + sb.AppendLine("-----BEGIN CERTIFICATE-----"); + for (var line = 0; line < 42; line++) + sb.AppendLine($"{b64.Substring((i + line + 1) % 4, 60)}{i * 65537 + line * 11:D8}=="); + sb.AppendLine("-----END CERTIFICATE-----"); + return sb.ToString(); + } + } +} From 269abb719aa5c9465b5e5876613c72e8551293ec Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Tue, 24 Feb 2026 11:40:19 +1030 Subject: [PATCH 7/9] Remove perf tests for Teamcity --- .../Fixtures/Bash/BashPerformanceFixture.cs | 297 ------------------ 1 file changed, 297 deletions(-) delete mode 100644 source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs diff --git a/source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs deleted file mode 100644 index 7781a64ada..0000000000 --- a/source/Calamari.Tests/Fixtures/Bash/BashPerformanceFixture.cs +++ /dev/null @@ -1,297 +0,0 @@ -using System.Linq; -using FluentAssertions; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using Calamari.Common.Features.Scripting; -using Calamari.Common.Features.Scripting.Bash; -using Calamari.Common.FeatureToggles; -using Calamari.Common.Plumbing.Variables; -using Calamari.Testing.Requirements; -using Calamari.Tests.Helpers; -using NUnit.Framework; - -namespace Calamari.Tests.Fixtures.Bash -{ - [TestFixture] - public class BashPerformanceFixture : CalamariFixture - { - [Category("Performance")] - [TestCase( 100, 1000, Description = "100 variables (~small deployment)")] - [TestCase( 500, 1200, Description = "500 variables (~medium deployment)")] - [TestCase(1_000, 1500, Description = "1 000 variables (~large deployment)")] - [TestCase(5_000, 4500, Description = "5 000 variables (~stress test)")] - [TestCase(20_000, 14_000, Description = "5 000 variables (~stress test)")] - [RequiresBashDotExeIfOnWindows] - public void ShouldPopulateOctopusParametersPerformantly(int variableCount, int timeLimitMs) - { - // Build the realistic payload separately so we can measure its size - // before it gets encrypted and written into the bootstrap script. - var perfVariables = BuildPerformanceTestVariables(variableCount); - var variables = new Dictionary(perfVariables) - .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); - - var keyBytes = perfVariables.Keys.Select(k => (long)Encoding.UTF8.GetByteCount(k)).ToArray(); - var valueBytes = perfVariables.Values.Select(v => (long)Encoding.UTF8.GetByteCount(v ?? "")).ToArray(); - var totalKeyBytes = keyBytes.Sum(); - var totalValueBytes = valueBytes.Sum(); - var totalPairBytes = totalKeyBytes + totalValueBytes; - - var stopwatch = Stopwatch.StartNew(); - var (output, _) = RunScript("count-octopus-parameters.sh", variables); - stopwatch.Stop(); - - var elapsedMs = stopwatch.ElapsedMilliseconds; - - // ── Structured performance report ──────────────────────────────────── - static string Kb(long b) => $"{b / 1024.0,7:F1} KB"; - static string Fmt(double b) => b >= 1024 ? $"{b / 1024.0:F1} KB" : $"{b:F0} B"; - - TestContext.WriteLine(""); - TestContext.WriteLine("── Payload ─────────────────────────────────────────────────────"); - TestContext.WriteLine($" Variables : {perfVariables.Count,6:N0}"); - TestContext.WriteLine($" Keys : avg {Fmt(keyBytes.Average()),9} │ min {keyBytes.Min(),5} B │ max {keyBytes.Max(),7} B │ total {Kb(totalKeyBytes)}"); - TestContext.WriteLine($" Values : avg {Fmt(valueBytes.Average()),9} │ min {valueBytes.Min(),5} B │ max {valueBytes.Max(),7} B │ total {Kb(totalValueBytes)}"); - TestContext.WriteLine($" Pairs : avg {Fmt(keyBytes.Average() + valueBytes.Average()),9} │ │ total {Kb(totalPairBytes)}"); - TestContext.WriteLine("── Timing ──────────────────────────────────────────────────────"); - TestContext.WriteLine($" Total : {elapsedMs,7} ms (limit {timeLimitMs / 1000} s)"); - TestContext.WriteLine($" Per var : {elapsedMs / (double)perfVariables.Count,9:F2} ms"); - TestContext.WriteLine($" Throughput : {totalPairBytes / 1024.0 / (elapsedMs / 1000.0),7:F1} KB/s"); - TestContext.WriteLine("────────────────────────────────────────────────────────────────"); - - output.AssertSuccess(); - - var fullOutput = string.Join(Environment.NewLine, output.CapturedOutput.Infos); - if (fullOutput.Contains("Bash version 4.2 or later is required to use octopus_parameters")) - { - Assert.Ignore("Bash 4.2+ required for octopus_parameters; performance assertion skipped."); - return; - } - - var countLine = output.GetOutputForLineContaining("VariableCount="); - var loadedCount = int.Parse(Regex.Match(countLine, @"VariableCount=(\d+)").Groups[1].Value); - loadedCount.Should().BeGreaterThanOrEqualTo( - variableCount, - $"octopus_parameters should contain at least the {variableCount} variables we passed in"); - - output.AssertOutput("SpotCheck=PerfSentinelValue"); - - elapsedMs.Should().BeLessThan( - timeLimitMs, - $"Loading {variableCount} variables should complete within {timeLimitMs / 1000.0:F0}s"); - } - - [Category("Performance")] - [TestCase(20_000, Description = "20 000 variables but array not loaded")] - [RequiresBashDotExeIfOnWindows] - public void ShouldNotLoadOctopusParametersWhenNotUsed(int variableCount) - { - // This test verifies that when a script doesn't use octopus_parameters, - // the encrypted variable string markers are still present in the configuration file - // (meaning the data is available for get_octopusvariable), but the array - // is NOT loaded, avoiding the ~6-7s overhead for large variable sets. - var perfVariables = BuildPerformanceTestVariables(variableCount); - var variables = new Dictionary(perfVariables) - .AddFeatureToggleToDictionary(new List { FeatureToggle.BashParametersArrayFeatureToggle }); - - var scriptFile = GetFixtureResource("Scripts", "no-octopus-parameters.sh"); - var workingDirectory = Path.GetDirectoryName(scriptFile); - - // Create the configuration file to inspect it - var script = new Script(scriptFile); - var calamariVariables = new CalamariVariables(); - foreach (var kvp in variables) - calamariVariables.Set(kvp.Key, kvp.Value); - - var configFile = BashScriptBootstrapper.PrepareConfigurationFile(workingDirectory, - calamariVariables, script); - - try - { - // Read the configuration file content - var configContent = File.ReadAllText(configFile); - - // Verify the encrypted variable string markers ARE still present - // (because script doesn't use octopus_parameters, the KVP data is NOT included, - // so the markers should remain unreplaced in the template) - configContent.Should().Contain("#### VARIABLESTRING.IV ####", - "The IV marker should remain unreplaced since the script doesn't use octopus_parameters"); - configContent.Should().Contain("#### VARIABLESTRING.ENCRYPTED ####", - "The encrypted marker should remain unreplaced since the script doesn't use octopus_parameters"); - configContent.Should().Contain("scriptUsesOctopusParameters=false", - "The script should be marked as NOT using octopus_parameters"); - - // Now run the script and verify timing - var stopwatch = Stopwatch.StartNew(); - var (output, _) = RunScript("no-octopus-parameters.sh", variables); - stopwatch.Stop(); - var elapsedMs = stopwatch.ElapsedMilliseconds; - - // Also measure how long it takes WITH the array loaded for comparison - var stopwatch2 = Stopwatch.StartNew(); - var (output2, _) = RunScript("count-octopus-parameters.sh", variables); - stopwatch2.Stop(); - var elapsedWithArrayMs = stopwatch2.ElapsedMilliseconds; - var savedMs = elapsedWithArrayMs - elapsedMs; - - TestContext.WriteLine(""); - TestContext.WriteLine($"Variables: {variableCount:N0}"); - TestContext.WriteLine($"Config file size: {new FileInfo(configFile).Length / 1024.0:F1} KB"); - TestContext.WriteLine($"Without array: {elapsedMs} ms"); - TestContext.WriteLine($"With array: {elapsedWithArrayMs} ms"); - TestContext.WriteLine($"Time saved: {savedMs} ms"); - - output.AssertSuccess(); - output.AssertOutput("ScriptRan=true"); - output.AssertOutput("SentinelValue=PerfSentinelValue"); - } - finally - { - if (File.Exists(configFile)) - File.Delete(configFile); - } - } - - // --------------------------------------------------------------------------- - // Realistic variable generator for performance tests. - // - // Produces a distribution that approximates a real Octopus deployment: - // - // Bucket | Share | Name length | Value length | Example - // --------|-------|--------------|----------------|---------------------------- - // Small | 60% | 15–30 chars | 10–40 chars | Project.Name, port numbers - // Medium | 25% | 40–65 chars | 150–300 chars | SQL / Redis connection strings - // Large | 10% | 70–130 chars| 700–950 chars | JSON config blobs - // Huge | 5% | 100–180 chars| 6 000–9 000 chars | PEM certificate + key bundles - // - // All keys are unique (every template appends the loop index). - // A "PerfSentinel" key is added for correctness spot-checks. - // --------------------------------------------------------------------------- - - static Dictionary BuildPerformanceTestVariables(int count) - { - var result = new Dictionary(count + 1); - for (var i = 0; i < count; i++) - { - var (key, value) = (i % 20) switch - { - < 12 => (PerfSmallKey(i), PerfSmallValue(i)), - < 17 => (PerfMediumKey(i), PerfMediumValue(i)), - < 19 => (PerfLargeKey(i), PerfLargeValue(i)), - _ => (PerfHugeKey(i), PerfHugeValue(i)), - }; - result[key] = value; - } - result["PerfSentinel"] = "PerfSentinelValue"; - return result; - } - - // Small (60%): names ~15–30 chars, values ~10–40 chars - static string PerfSmallKey(int i) => (i % 12) switch - { - 0 => $"Project.Name.{i:D4}", - 1 => $"Environment.Region.{i:D4}", - 2 => $"Application.Version.{i:D4}", - 3 => $"Config.Timeout.Seconds.{i:D4}", - 4 => $"Deploy.Mode.{i:D4}", - 5 => $"Service.Port.{i:D4}", - 6 => $"Feature.{i:D4}.Enabled", - 7 => $"Build.Number.{i:D4}", - 8 => $"Cluster.Zone.{i:D4}", - 9 => $"Agent.Pool.{i:D4}", - 10 => $"Release.Channel.{i:D4}", - _ => $"Tag.Value.{i:D4}", - }; - - static string PerfSmallValue(int i) => (i % 12) switch - { - 0 => $"production-{i % 5}", - 1 => $"us-{(i % 2 == 0 ? "east" : "west")}-{i % 3 + 1}", - 2 => $"v{i % 10}.{i % 100 + 1}.{i % 1000}", - 3 => $"{i % 120 + 10}", - 4 => i % 2 == 0 ? "blue-green" : "rolling", - 5 => $"{8080 + i % 100}", - 6 => i % 2 == 0 ? "true" : "false", - 7 => $"build-{i:D6}", - 8 => $"zone-{(char)('a' + i % 3)}", - 9 => $"pool-{i % 4}", - 10 => i % 2 == 0 ? "stable" : "beta", - _ => $"tag-{i % 20:D3}", - }; - - // Medium (25%): names ~40–65 chars, values ~150–300 chars - static string PerfMediumKey(int i) => (i % 5) switch - { - 0 => $"Application.Database.Primary.ConnectionString.{i:D4}", - 1 => $"Azure.Storage.Account{i % 3}.AccessKey.{i:D4}", - 2 => $"Service.Authentication.BearerToken.Endpoint.{i:D4}", - 3 => $"Infrastructure.Cache.Redis{i % 2}.ConnectionString.{i:D4}", - _ => $"Octopus.Action.Package[MyApp.Service{i % 5}].FeedUri.{i:D4}", - }; - - static string PerfMediumValue(int i) => (i % 5) switch - { - 0 => $"Server=tcp:sql{i % 3}.database.windows.net,1433;Initial Catalog=AppDb{i % 10};User Id=svc_app;Password=P@ssw0rd{i:D4}!;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;", - 1 => $"DefaultEndpointsProtocol=https;AccountName=storage{i % 5}acct;AccountKey=FAKE-KEY-NOT-A-SECRET-{i:D4};EndpointSuffix=core.windows.net", - 2 => $"https://login.microsoftonline.com/{i:D8}-aaaa-bbbb-cccc-{i:D12}/oauth2/v2.0/token", - 3 => $"cache{i % 2}.redis.cache.windows.net:6380,password=cacheSecret{i:D4}==,ssl=true,abortConnect=false,connectTimeout=5000,syncTimeout=3000", - _ => $"https://nuget.pkg.github.com/MyOrganisation{i % 3}/index.json?api-version=6.0", - }; - - // Large (10%): names ~70–130 chars, values ~700–950 chars (JSON config blobs) - static string PerfLargeKey(int i) => - $"Octopus.Action[Step {i % 10}: Deploy {(i % 2 == 0 ? "Application" : "Infrastructure")} to {(i % 3 == 0 ? "Production" : "Staging")}].Package[MyCompany.Service{i % 5}].Config.{i:D4}"; - - static string PerfLargeValue(int i) => $@"{{ - ""environment"": ""{(i % 2 == 0 ? "production" : "staging")}"", - ""instanceId"": ""{i:D8}"", - ""serviceEndpoints"": {{ - ""auth"": ""https://auth{i % 3}.internal.company.com/oauth/token"", - ""users"": ""https://users{i % 3}.internal.company.com/api/v2"", - ""events"": ""https://events{i % 3}.internal.company.com/stream"" - }}, - ""database"": {{ - ""primary"": ""Server=tcp:primary{i % 2}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_app;Password=P@ssw0rd{i:D6}!;Encrypt=True;Connection Timeout=30;"", - ""replica"": ""Server=tcp:replica{i % 3}.db.internal,1433;Initial Catalog=AppDb;User ID=svc_ro;Password=R3adOnly{i:D6}!;Encrypt=True;Connection Timeout=30;"" - }}, - ""cache"": {{ - ""primary"": ""cache{i % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"", - ""secondary"": ""cache{(i + 1) % 2}.redis.cache.windows.net:6380,password=cacheP@ss{i:D4}==,ssl=true"" - }}, - ""storage"": {{ - ""accountName"": ""storage{i % 5}acct"", - ""containerName"": ""deployments-{i:D4}"", - ""sasToken"": ""sv=2021-12-02&ss=b&srt=co&sp=rwdlacupiytfx&se=2025-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=fakeSignature{i:D6}=="" - }} -}}"; - - // Huge (5%): names ~100–180 chars, values ~6 000–9 000 chars (PEM cert + private key bundle) - static string PerfHugeKey(int i) => - $"Octopus.Action[Step {i % 5}: Long Running {(i % 2 == 0 ? "Database Migration" : "Certificate Rotation")} Task For {(i % 3 == 0 ? "Production-AUS" : "Production-US")} Environment].Package[MyCompany.{(i % 2 == 0 ? "Infrastructure" : "Security")}.Tooling.v{i % 10}].Config.{i:D4}"; - - static string PerfHugeValue(int i) - { - // Each cert line is 60 chars of base64 + 8-digit serial + "==" + newline ≈ 72 chars. - // 60 + 52 + 42 lines across 3 PEM blocks ≈ 154 lines × 72 chars ≈ 11 KB per variable. - const string b64 = "MIIGXTCCBEWgAwIBAgIJAKnmpBuMNbOBMA0GCSqGSIb3DQEBCwUAMIGaMQswCQYD"; // 64 chars - var sb = new StringBuilder(9000); - sb.AppendLine($"# Certificate bundle — slot {i % 3}, serial {i:D8}"); - sb.AppendLine("-----BEGIN CERTIFICATE-----"); - for (var line = 0; line < 60; line++) - sb.AppendLine($"{b64.Substring((i + line) % 4, 60)}{i * 31337 + line * 7:D8}=="); - sb.AppendLine("-----END CERTIFICATE-----"); - sb.AppendLine("-----BEGIN FAKE TEST KEY-----"); - for (var line = 0; line < 52; line++) - sb.AppendLine($"{b64.Substring((i + line + 2) % 4, 60)}{i * 99991 + line * 13:D8}Ag=="); - sb.AppendLine("-----END FAKE TEST KEY-----"); - sb.AppendLine("-----BEGIN CERTIFICATE-----"); - for (var line = 0; line < 42; line++) - sb.AppendLine($"{b64.Substring((i + line + 1) % 4, 60)}{i * 65537 + line * 11:D8}=="); - sb.AppendLine("-----END CERTIFICATE-----"); - return sb.ToString(); - } - } -} From 6543078b7f9aefecc8e66d652de2d7bcf692c781 Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Tue, 24 Feb 2026 12:04:47 +1030 Subject: [PATCH 8/9] Only run ShouldBeAbleToEnumerateVariableValues for bash parameter feature toggle enabled --- source/Calamari.Tests/Fixtures/Bash/BashFixture.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs index 36ff193d8f..5cd47e7180 100644 --- a/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs +++ b/source/Calamari.Tests/Fixtures/Bash/BashFixture.cs @@ -336,7 +336,6 @@ public void ShouldSupportStrictVariableUnset(FeatureToggle? featureToggle) static string specialCharacters => "! \" # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \\ ] ^ _ ` { | } ~ \n\u00b1 \u00d7 \u00f7 \u2211 \u220f \u2202 \u221e \u222b \u2248 \u2260 \u2264 \u2265 \u221a \u221b \u2206 \u2207 \u221d \n$ \u00a2 \u00a3 \u00a5 \u20ac \u20b9 \u20a9 \u20b1 \u20aa \u20bf \n• ‣ … ′ ″ ‘ ’ “ ” ‽ ¡ ¿ – — ― \n( ) [ ] { } ⟨ ⟩ « » ‘ ’ “ ” \n\u2190 \u2191 \u2192 \u2193 \u2194 \u2195 \u2196 \u2197 \u2198 \u2199 \u2b05 \u2b06 \u2b07 \u27a1 \u27f3 \nα β γ δ ε ζ η θ ι κ λ μ ν ξ ο π ρ σ τ υ φ χ ψ ω \n\u00a9 \u00ae \u2122 § ¶ † ‡ µ #"; [TestCase(FeatureToggle.BashParametersArrayFeatureToggle)] - [TestCase(null)] [RequiresBashDotExeIfOnWindows] public void ShouldBeAbleToEnumerateVariableValues(FeatureToggle? featureToggle) { From b98f597a68d3a2f18b71a9b1c49521a92319e4c2 Mon Sep 17 00:00:00 2001 From: IsaacCalligeros95 Date: Tue, 24 Feb 2026 13:01:14 +1030 Subject: [PATCH 9/9] Tidy-up --- .../Bash/Scripts/count-octopus-parameters.sh | 14 -------------- .../Fixtures/Bash/Scripts/no-octopus-parameters.sh | 8 -------- 2 files changed, 22 deletions(-) delete mode 100644 source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh delete mode 100644 source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh diff --git a/source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh b/source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh deleted file mode 100644 index 80828d6d33..0000000000 --- a/source/Calamari.Tests/Fixtures/Bash/Scripts/count-octopus-parameters.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -# Used for performance testing of decrypt_and_parse_variables. -# Counts the number of entries in octopus_parameters and prints the total, -# along with a spot-check of a known sentinel key to verify correctness. - -count=0 -for key in "${!octopus_parameters[@]}"; do - count=$(( count + 1 )) -done - -echo "VariableCount=$count" - -# Spot-check: BuildPerformanceTestVariables always injects this sentinel. -echo "SpotCheck=${octopus_parameters[PerfSentinel]}" diff --git a/source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh b/source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh deleted file mode 100644 index b05a77b5c6..0000000000 --- a/source/Calamari.Tests/Fixtures/Bash/Scripts/no-octopus-parameters.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -# Script that deliberately does NOT use octopus_parameters -# Used to test that the array is not loaded when not needed - -# Use get_octopusvariable instead (uses the switch statement, not the array) -name=$(get_octopusvariable "PerfSentinel") -echo "ScriptRan=true" -echo "SentinelValue=$name"