Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,39 +166,39 @@ 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 "<kind>/<name> <verb>", e.g.:
// customresourcedefinition.apiextensions.k8s.io/widgets.example.com created
// 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();
}
Expand Down
32 changes: 32 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.K3s/K3sShellScript.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Text;

namespace CommunityToolkit.Aspire.Hosting;

/// <summary>
/// Helpers for composing the POSIX shell scripts that are generated on the host and
/// injected into the Linux helper containers (<c>alpine/helm</c>, <c>alpine/kubectl</c>).
/// </summary>
internal static class K3sShellScript
{
/// <summary>
/// Appends <paramref name="line"/> followed by a single LF (<c>\n</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Never use <see cref="StringBuilder.AppendLine(string)"/> for generated shell
/// scripts.</b> It appends <see cref="Environment.NewLine"/>, which is CRLF on Windows.
/// The scripts are executed by busybox <c>ash</c> inside a Linux container, and busybox
/// does not strip the trailing CR: a line such as <c>if [ ... ]; then</c> is tokenized
/// as <c>then\r</c>, which is not the <c>then</c> keyword. The <c>if</c> is therefore
/// never closed and the script aborts at EOF with
/// <c>syntax error: unexpected end of file (expecting "then")</c>.
/// </para>
/// <para>
/// The bug is invisible on Linux and macOS build agents (where
/// <see cref="Environment.NewLine"/> is already LF), so it only ever surfaces for
/// developers running the AppHost on Windows.
/// </para>
/// </remarks>
internal static StringBuilder AppendShellLine(this StringBuilder builder, string line) =>
builder.Append(line).Append('\n');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["server.service.type"] = "NodePort" }));

Assert.DoesNotContain('\r', script);
}

[Fact]
public void BuildHelmScriptIncludesWaitAndNamespace()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading