From 5515c1ebe336624f1245260187417fe5cec7bf5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20R=C3=B6schmann?= Date: Mon, 31 Aug 2026 08:31:24 +0200 Subject: [PATCH] feat(1550): Ensure LF line endings in generated shell scripts to prevent syntax errors in busybox --- .../K3sBuilderExtensions.Helm.cs | 24 +++++----- .../K3sBuilderExtensions.Manifest.cs | 44 +++++++++---------- .../K3sShellScript.cs | 32 ++++++++++++++ .../HelmReleaseResourceTests.cs | 14 ++++++ .../K8sManifestResourceTests.cs | 12 +++++ 5 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.K3s/K3sShellScript.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Helm.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Helm.cs index 304298691..0c7cbe9ba 100644 --- a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Helm.cs +++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Helm.cs @@ -224,22 +224,22 @@ internal static string BuildHelmScript(HelmReleaseResource release) // can appear in the bind-mount before the k8s hostname resolves in the helm // container. Using `helm list` (which calls the k8s API) verifies both the // file and the network path before proceeding. - sb.AppendLine("_k3s_wait=0"); - sb.AppendLine($"until [ -f {K3sFileHelpers.ContainerKubeconfigPath} ] && helm list --kubeconfig {K3sFileHelpers.ContainerKubeconfigPath} > /dev/null 2>&1; do"); - sb.AppendLine(" _k3s_wait=$((_k3s_wait + 5))"); - sb.AppendLine(" if [ \"$_k3s_wait\" -ge 600 ]; then"); - sb.AppendLine(" echo 'Timed out waiting for k3s cluster to be ready after 10 minutes' >&2"); - sb.AppendLine(" exit 1"); - sb.AppendLine(" fi"); - sb.AppendLine(" echo 'Waiting for k3s cluster to be ready and reachable...'"); - sb.AppendLine(" sleep 5"); - sb.AppendLine("done"); + sb.AppendShellLine("_k3s_wait=0"); + sb.AppendShellLine($"until [ -f {K3sFileHelpers.ContainerKubeconfigPath} ] && helm list --kubeconfig {K3sFileHelpers.ContainerKubeconfigPath} > /dev/null 2>&1; do"); + sb.AppendShellLine(" _k3s_wait=$((_k3s_wait + 5))"); + sb.AppendShellLine(" if [ \"$_k3s_wait\" -ge 600 ]; then"); + sb.AppendShellLine(" echo 'Timed out waiting for k3s cluster to be ready after 10 minutes' >&2"); + sb.AppendShellLine(" exit 1"); + sb.AppendShellLine(" fi"); + sb.AppendShellLine(" echo 'Waiting for k3s cluster to be ready and reachable...'"); + sb.AppendShellLine(" sleep 5"); + sb.AppendShellLine("done"); if (release.RepoUrl is not null) { var alias = $"aspire-k3s-{release.ReleaseName}"; - sb.AppendLine($"helm repo add --force-update {ShellEscape(alias)} {ShellEscape(release.RepoUrl)}"); - sb.AppendLine($"helm repo update {ShellEscape(alias)}"); + sb.AppendShellLine($"helm repo add --force-update {ShellEscape(alias)} {ShellEscape(release.RepoUrl)}"); + sb.AppendShellLine($"helm repo update {ShellEscape(alias)}"); } var chartRef = release.RepoUrl is not null diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Manifest.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Manifest.cs index 159eef255..e9b4e378c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Manifest.cs +++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sBuilderExtensions.Manifest.cs @@ -166,27 +166,27 @@ internal static string BuildManifestScript() // DCP sets up container network aliases asynchronously, so the kubeconfig file // can appear in the bind-mount before the k8s hostname resolves in the kubectl // container. Using `kubectl cluster-info` verifies both the file and the network. - sb.AppendLine("_k3s_wait=0"); - sb.AppendLine($"until [ -f {K3sFileHelpers.ContainerKubeconfigPath} ] && kubectl cluster-info --kubeconfig {K3sFileHelpers.ContainerKubeconfigPath} > /dev/null 2>&1; do"); - sb.AppendLine(" _k3s_wait=$((_k3s_wait + 5))"); - sb.AppendLine(" if [ \"$_k3s_wait\" -ge 600 ]; then"); - sb.AppendLine(" echo 'Timed out waiting for k3s cluster to be ready after 10 minutes' >&2"); - sb.AppendLine(" exit 1"); - sb.AppendLine(" fi"); - sb.AppendLine(" echo 'Waiting for k3s cluster to be ready and reachable...'"); - sb.AppendLine(" sleep 5"); - sb.AppendLine("done"); + sb.AppendShellLine("_k3s_wait=0"); + sb.AppendShellLine($"until [ -f {K3sFileHelpers.ContainerKubeconfigPath} ] && kubectl cluster-info --kubeconfig {K3sFileHelpers.ContainerKubeconfigPath} > /dev/null 2>&1; do"); + sb.AppendShellLine(" _k3s_wait=$((_k3s_wait + 5))"); + sb.AppendShellLine(" if [ \"$_k3s_wait\" -ge 600 ]; then"); + sb.AppendShellLine(" echo 'Timed out waiting for k3s cluster to be ready after 10 minutes' >&2"); + sb.AppendShellLine(" exit 1"); + sb.AppendShellLine(" fi"); + sb.AppendShellLine(" echo 'Waiting for k3s cluster to be ready and reachable...'"); + sb.AppendShellLine(" sleep 5"); + sb.AppendShellLine("done"); // Auto-detect kustomize: if a kustomization file is present, use -k. // Otherwise use -f with server-side apply. // Capture output so we can extract any CRD names that were applied. - sb.AppendLine("if [ -f /k8s-manifests/kustomization.yaml ] || [ -f /k8s-manifests/kustomization.yml ]; then"); - sb.AppendLine(" echo 'Detected kustomization — using kubectl apply -k'"); - sb.AppendLine(" APPLIED=$(kubectl apply -k /k8s-manifests --server-side --field-manager=aspire-k3s --force-conflicts)"); - sb.AppendLine("else"); - sb.AppendLine(" APPLIED=$(kubectl apply -f /k8s-manifests --server-side --field-manager=aspire-k3s --force-conflicts)"); - sb.AppendLine("fi"); - sb.AppendLine("echo \"$APPLIED\""); + sb.AppendShellLine("if [ -f /k8s-manifests/kustomization.yaml ] || [ -f /k8s-manifests/kustomization.yml ]; then"); + sb.AppendShellLine(" echo 'Detected kustomization — using kubectl apply -k'"); + sb.AppendShellLine(" APPLIED=$(kubectl apply -k /k8s-manifests --server-side --field-manager=aspire-k3s --force-conflicts)"); + sb.AppendShellLine("else"); + sb.AppendShellLine(" APPLIED=$(kubectl apply -f /k8s-manifests --server-side --field-manager=aspire-k3s --force-conflicts)"); + sb.AppendShellLine("fi"); + sb.AppendShellLine("echo \"$APPLIED\""); // Parse the apply output for CRD names — kubectl apply prints one line per resource // in the form "/ ", e.g.: @@ -194,11 +194,11 @@ internal static string BuildManifestScript() // Only lines starting with "customresourcedefinition." belong to this apply. // This avoids touching pre-existing or concurrently installed cluster CRDs and // prevents busybox xargs from returning exit code 123 when grep finds no match. - sb.AppendLine("CRD_NAMES=$(echo \"$APPLIED\" | grep '^customresourcedefinition\\.' | awk '{print $1}')"); - sb.AppendLine("if [ -n \"$CRD_NAMES\" ]; then"); - sb.AppendLine(" # shellcheck disable=SC2086"); - sb.AppendLine(" kubectl wait --for=condition=Established $CRD_NAMES --timeout=300s"); - sb.AppendLine("fi"); + sb.AppendShellLine("CRD_NAMES=$(echo \"$APPLIED\" | grep '^customresourcedefinition\\.' | awk '{print $1}')"); + sb.AppendShellLine("if [ -n \"$CRD_NAMES\" ]; then"); + sb.AppendShellLine(" # shellcheck disable=SC2086"); + sb.AppendShellLine(" kubectl wait --for=condition=Established $CRD_NAMES --timeout=300s"); + sb.AppendShellLine("fi"); return sb.ToString(); } diff --git a/src/CommunityToolkit.Aspire.Hosting.K3s/K3sShellScript.cs b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sShellScript.cs new file mode 100644 index 000000000..580cac1fd --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.K3s/K3sShellScript.cs @@ -0,0 +1,32 @@ +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting; + +/// +/// Helpers for composing the POSIX shell scripts that are generated on the host and +/// injected into the Linux helper containers (alpine/helm, alpine/kubectl). +/// +internal static class K3sShellScript +{ + /// + /// Appends followed by a single LF (\n). + /// + /// + /// + /// Never use for generated shell + /// scripts. It appends , which is CRLF on Windows. + /// The scripts are executed by busybox ash inside a Linux container, and busybox + /// does not strip the trailing CR: a line such as if [ ... ]; then is tokenized + /// as then\r, which is not the then keyword. The if is therefore + /// never closed and the script aborts at EOF with + /// syntax error: unexpected end of file (expecting "then"). + /// + /// + /// The bug is invisible on Linux and macOS build agents (where + /// is already LF), so it only ever surfaces for + /// developers running the AppHost on Windows. + /// + /// + internal static StringBuilder AppendShellLine(this StringBuilder builder, string line) => + builder.Append(line).Append('\n'); +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/HelmReleaseResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/HelmReleaseResourceTests.cs index 0fba5614e..316319635 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/HelmReleaseResourceTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/HelmReleaseResourceTests.cs @@ -224,6 +224,20 @@ public void BuildHelmScriptIncludesUpgradeInstall() Assert.Contains("'argo-cd'", script); } + [Fact] + public void BuildHelmScriptUsesLfLineEndingsOnly() + { + // Regression: StringBuilder.AppendLine emits Environment.NewLine (CRLF on Windows). + // busybox ash in alpine/helm does not strip the CR, so `then\r` is not the `then` + // keyword and the script dies with + // "syntax error: unexpected end of file (expecting \"then\")". + var script = K3sHelmBuilderExtensions.BuildHelmScript( + MakeRelease("argocd", "argo-cd", "https://argoproj.github.io/argo-helm", "7.8.0", "argocd", + new Dictionary { ["server.service.type"] = "NodePort" })); + + Assert.DoesNotContain('\r', script); + } + [Fact] public void BuildHelmScriptIncludesWaitAndNamespace() { diff --git a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K8sManifestResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K8sManifestResourceTests.cs index aa8f67fc8..66ed37469 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K8sManifestResourceTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.K3s.Tests/K8sManifestResourceTests.cs @@ -195,6 +195,18 @@ public void BuildManifestScriptIncludesKubectlApply() Assert.Contains("--server-side", script); } + [Fact] + public void BuildManifestScriptUsesLfLineEndingsOnly() + { + // Regression: StringBuilder.AppendLine emits Environment.NewLine (CRLF on Windows). + // busybox ash in alpine/kubectl does not strip the CR, so `then\r` is not the + // `then` keyword and the script dies with + // "syntax error: unexpected end of file (expecting \"then\")". + var script = K3sManifestBuilderExtensions.BuildManifestScript(); + + Assert.DoesNotContain('\r', script); + } + [Fact] public void BuildManifestScriptAutoDetectsKustomize() {