diff --git a/cmd/kubectl-unbounded/app/assets/node-bootstrap/cloud-init.yaml b/cmd/kubectl-unbounded/app/assets/node-bootstrap/cloud-init.yaml
index 5bc71cbae..1e91d294f 100644
--- a/cmd/kubectl-unbounded/app/assets/node-bootstrap/cloud-init.yaml
+++ b/cmd/kubectl-unbounded/app/assets/node-bootstrap/cloud-init.yaml
@@ -19,4 +19,7 @@ write_files:
runcmd:
- export UNBOUNDED_AGENT_CONFIG_FILE=/etc/unbounded/agent/config.json
+{{- range .InstallEnv }}
+ - export {{ . }}
+{{- end }}
- bash /usr/local/bin/unbounded-agent-install.sh
diff --git a/cmd/kubectl-unbounded/app/assets/node-bootstrap/script.sh b/cmd/kubectl-unbounded/app/assets/node-bootstrap/script.sh
index dda2e4562..bb67d837b 100644
--- a/cmd/kubectl-unbounded/app/assets/node-bootstrap/script.sh
+++ b/cmd/kubectl-unbounded/app/assets/node-bootstrap/script.sh
@@ -25,13 +25,16 @@ cat > "${UNBOUNDED_AGENT_CONFIG_FILE}" <<'AGENT_CONFIG_EOF'
AGENT_CONFIG_EOF
# -----------------------------------------------------------------
-# Embedded install script
+# Agent download overrides (injected by manual-bootstrap flags)
# -----------------------------------------------------------------
-
-{{- if .AgentURLLiteral }}
-export AGENT_URL={{ .AgentURLLiteral }}
+{{- range .InstallEnv }}
+export {{ . }}
{{- end }}
+# -----------------------------------------------------------------
+# Embedded install script
+# -----------------------------------------------------------------
+
bash <<'INSTALL_SCRIPT_EOF'
{{ .InstallScript }}
INSTALL_SCRIPT_EOF
diff --git a/cmd/kubectl-unbounded/app/machine_manual_bootstrap.go b/cmd/kubectl-unbounded/app/machine_manual_bootstrap.go
index af9bdaa6a..3b9494b39 100644
--- a/cmd/kubectl-unbounded/app/machine_manual_bootstrap.go
+++ b/cmd/kubectl-unbounded/app/machine_manual_bootstrap.go
@@ -83,12 +83,24 @@ type manualBootstrapHandler struct {
// via the discovery client.
kubernetesVersion string
- // variant controls the output format. Defaults to "script".
- variant string
+ // agentVersion pins the unbounded-agent release tag to download on the
+ // target host. When empty (the default) the install script tracks the
+ // latest published release.
+ agentVersion string
- // agentURL optionally overrides the embedded install script download URL.
+ // agentURL is a fully qualified override for the unbounded-agent download
+ // URL. When set it takes precedence over agentVersion and agentBaseURL.
agentURL string
+ // agentBaseURL overrides the base URL used to construct the download URL
+ // for the unbounded-agent. Useful for self-hosted release mirrors. Must
+ // follow the same layout as GitHub releases
+ // (/latest/download/ and /download//).
+ agentBaseURL string
+
+ // variant controls the output format. Defaults to "script".
+ variant string
+
// kubeconfigPath is the path to the kubeconfig used to contact the cluster.
kubeconfigPath string
@@ -173,10 +185,6 @@ func (h *manualBootstrapHandler) validate() error {
return err
}
- if h.agentURL != "" && bootstrapVariant(h.variant) != variantScript {
- return errors.New("--agent-url is only supported with --variant script")
- }
-
h.kubeconfigPath = getKubeconfigPath(h.kubeconfigPath)
if !isReadableFile(h.kubeconfigPath) {
@@ -293,13 +301,38 @@ type manualBootstrapTemplateData struct {
// heredoc that is piped to bash.
InstallScript string
- // AgentURLLiteral optionally overrides the agent download location used by
- // the embedded install script as a shell-safe literal.
- AgentURLLiteral string
+ // InstallEnv is an optional list of "KEY=VALUE" strings that are exported
+ // in the generated script's shell (or cloud-init runcmd) immediately
+ // before the embedded install script runs. Used to forward agent
+ // download overrides like AGENT_VERSION, AGENT_URL, and AGENT_BASE_URL.
+ InstallEnv []string
}
-func shellQuote(s string) string {
- return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
+// installEnv returns the KEY=VALUE pairs that should be exported before the
+// embedded install script runs. Only non-empty overrides are included.
+func (h *manualBootstrapHandler) installEnv() []string {
+ var env []string
+
+ if h.agentVersion != "" {
+ env = append(env, fmt.Sprintf("AGENT_VERSION=%s", shellSingleQuote(h.agentVersion)))
+ }
+
+ if h.agentBaseURL != "" {
+ env = append(env, fmt.Sprintf("AGENT_BASE_URL=%s", shellSingleQuote(h.agentBaseURL)))
+ }
+
+ if h.agentURL != "" {
+ env = append(env, fmt.Sprintf("AGENT_URL=%s", shellSingleQuote(h.agentURL)))
+ }
+
+ return env
+}
+
+// shellSingleQuote wraps v in POSIX-safe single quotes, escaping any embedded
+// single quotes. The result can be used verbatim on the right-hand side of
+// an `export KEY=...` statement in bash.
+func shellSingleQuote(v string) string {
+ return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
}
// renderScript produces a self-contained bash script that writes the agent
@@ -315,10 +348,7 @@ func (h *manualBootstrapHandler) renderScript(cfg *provision.AgentConfig) (strin
MachineName: cfg.MachineName,
AgentConfigJSON: string(configJSON),
InstallScript: provision.UnboundedAgentInstallScript(),
- }
-
- if h.agentURL != "" {
- data.AgentURLLiteral = shellQuote(h.agentURL)
+ InstallEnv: h.installEnv(),
}
t, err := template.New("node-bootstrap").Parse(manualBootstrapTemplate)
@@ -346,6 +376,7 @@ func (h *manualBootstrapHandler) renderCloudInit(cfg *provision.AgentConfig) (st
MachineName: cfg.MachineName,
AgentConfigJSON: string(configJSON),
InstallScript: provision.UnboundedAgentInstallScript(),
+ InstallEnv: h.installEnv(),
}
funcMap := template.FuncMap{
@@ -381,7 +412,7 @@ func newMachineManualBootstrapCommand(handler *manualBootstrapHandler) *cobra.Co
Use: "manual-bootstrap NAME",
Short: "Generate a bootstrap script or cloud-init config for provisioning a machine",
Long: `Generate a self-contained bootstrap payload that provisions a bare-metal or VM
-host as an unbounded-kube worker node. The payload embeds the agent JSON
+host as an unbounded worker node. The payload embeds the agent JSON
configuration inline and the install script for the target architecture.
Use --variant to choose the output format:
@@ -398,7 +429,15 @@ Examples:
kubectl unbounded machine manual-bootstrap my-node --site my-site | ssh user@host sudo bash
# Generate cloud-init user-data for a cloud provider API:
- kubectl unbounded machine manual-bootstrap my-node --site my-site --variant cloud-init > user-data.yaml`,
+ kubectl unbounded machine manual-bootstrap my-node --site my-site --variant cloud-init > user-data.yaml
+
+ # Pin the agent to a specific release instead of tracking "latest":
+ kubectl unbounded machine manual-bootstrap my-node --site my-site --agent-version v0.0.10
+
+ # Self-host / mirror the release assets (expects the same layout as
+ # GitHub releases under the base URL):
+ kubectl unbounded machine manual-bootstrap my-node --site my-site \
+ --agent-base-url https://releases.example.com/unbounded`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
handler.machineName = args[0]
@@ -413,7 +452,9 @@ Examples:
cmd.Flags().StringVar(&handler.ociImage, "oci-image", "", "OCI image reference for the agent rootfs")
cmd.Flags().StringVar(&handler.kubernetesVersion, "kubernetes-version", "", "Override the Kubernetes version (default: auto-detected from API server)")
cmd.Flags().StringVar(&handler.variant, "variant", "script", "Output format: script or cloud-init")
- cmd.Flags().StringVar(&handler.agentURL, "agent-url", "", "Override URL used by the embedded agent install script")
+ cmd.Flags().StringVar(&handler.agentVersion, "agent-version", "", "Pin the unbounded-agent release tag to download on the host (default: latest GitHub release)")
+ cmd.Flags().StringVar(&handler.agentURL, "agent-url", "", "Fully qualified download URL for the unbounded-agent tarball (overrides --agent-version and --agent-base-url)")
+ cmd.Flags().StringVar(&handler.agentBaseURL, "agent-base-url", "", "Base URL for unbounded-agent release downloads (default: https://github.com/Azure/unbounded/releases). Use this to self-host or mirror release assets")
if err := cmd.MarkFlagRequired("site"); err != nil {
panic(err)
diff --git a/cmd/kubectl-unbounded/app/machine_manual_bootstrap_test.go b/cmd/kubectl-unbounded/app/machine_manual_bootstrap_test.go
index 95d284362..3f06a5abb 100644
--- a/cmd/kubectl-unbounded/app/machine_manual_bootstrap_test.go
+++ b/cmd/kubectl-unbounded/app/machine_manual_bootstrap_test.go
@@ -7,6 +7,8 @@ import (
"bytes"
"context"
"encoding/json"
+ "os"
+ "os/exec"
"strings"
"testing"
@@ -104,17 +106,6 @@ func TestManualBootstrapHandler_Validate(t *testing.T) {
variant: "cloud-init",
},
},
- {
- name: "invalid: agent-url with cloud-init variant",
- handler: manualBootstrapHandler{
- siteName: "dc1",
- machineName: "my-node",
- kubeconfigPath: kubeconfigPath,
- variant: "cloud-init",
- agentURL: "file:///tmp/unbounded-agent-linux-amd64.tar.gz",
- },
- expectErr: "--agent-url is only supported with --variant script",
- },
{
name: "valid: variant defaults to script when empty",
handler: manualBootstrapHandler{
@@ -289,6 +280,13 @@ func TestManualBootstrapHandler_RenderScript(t *testing.T) {
script, err := h.renderScript(cfg)
require.NoError(t, err)
+ // No download overrides: no `export AGENT_*=` lines should appear in
+ // the outer script (the embedded install script references the vars
+ // in its own help text, but must not be pre-set by the wrapper).
+ require.NotContains(t, script, "export AGENT_VERSION=")
+ require.NotContains(t, script, "export AGENT_URL=")
+ require.NotContains(t, script, "export AGENT_BASE_URL=")
+
// Should start with a shebang.
require.Contains(t, script, "#!/bin/bash")
require.Contains(t, script, "set -eo pipefail")
@@ -601,3 +599,163 @@ func TestManualBootstrapHandler_Execute(t *testing.T) {
require.Contains(t, output, "/etc/unbounded/agent/config.json")
})
}
+
+// ---------------------------------------------------------------------------
+// Agent download override tests
+// ---------------------------------------------------------------------------
+
+func TestManualBootstrapHandler_InstallEnv(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ handler manualBootstrapHandler
+ want []string
+ }{
+ {
+ name: "no overrides",
+ handler: manualBootstrapHandler{},
+ want: nil,
+ },
+ {
+ name: "pinned version",
+ handler: manualBootstrapHandler{agentVersion: "v0.0.10"},
+ want: []string{"AGENT_VERSION='v0.0.10'"},
+ },
+ {
+ name: "base URL override",
+ handler: manualBootstrapHandler{agentBaseURL: "https://mirror.example.com/releases"},
+ want: []string{"AGENT_BASE_URL='https://mirror.example.com/releases'"},
+ },
+ {
+ name: "full URL override",
+ handler: manualBootstrapHandler{agentURL: "https://mirror.example.com/agent.tar.gz"},
+ want: []string{"AGENT_URL='https://mirror.example.com/agent.tar.gz'"},
+ },
+ {
+ name: "all three set",
+ handler: manualBootstrapHandler{
+ agentVersion: "v0.0.10",
+ agentBaseURL: "https://mirror.example.com/releases",
+ agentURL: "https://mirror.example.com/agent.tar.gz",
+ },
+ want: []string{
+ "AGENT_VERSION='v0.0.10'",
+ "AGENT_BASE_URL='https://mirror.example.com/releases'",
+ "AGENT_URL='https://mirror.example.com/agent.tar.gz'",
+ },
+ },
+ {
+ name: "value containing a single quote is escaped",
+ handler: manualBootstrapHandler{agentVersion: "v'1"},
+ want: []string{`AGENT_VERSION='v'\''1'`},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got := tt.handler.installEnv()
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestManualBootstrapHandler_RenderScript_DownloadOverrides(t *testing.T) {
+ t.Parallel()
+
+ cfg := &provision.AgentConfig{
+ MachineName: "test-node",
+ Cluster: provision.AgentClusterConfig{
+ CaCertBase64: "dGVzdA==",
+ ClusterDNS: "10.0.0.10",
+ Version: "v1.30.0",
+ },
+ Kubelet: provision.AgentKubeletConfig{
+ ApiServer: "https://api-server:6443",
+ BootstrapToken: "abc123.0123456789abcdef",
+ Labels: map[string]string{"env": "prod"},
+ },
+ }
+
+ h := &manualBootstrapHandler{
+ logger: discardLogger(),
+ agentVersion: "v0.0.10",
+ agentBaseURL: "https://mirror.example.com/releases",
+ }
+
+ script, err := h.renderScript(cfg)
+ require.NoError(t, err)
+
+ // Overrides must be exported in the outer shell before the embedded
+ // install script heredoc.
+ require.Contains(t, script, "export AGENT_VERSION='v0.0.10'")
+ require.Contains(t, script, "export AGENT_BASE_URL='https://mirror.example.com/releases'")
+
+ // The exports must appear before the embedded install script heredoc.
+ exportIdx := strings.Index(script, "export AGENT_VERSION=")
+ heredocIdx := strings.Index(script, "INSTALL_SCRIPT_EOF")
+ require.Greater(t, exportIdx, 0)
+ require.Greater(t, heredocIdx, exportIdx)
+
+ // Script should still be valid bash syntax.
+ requireValidBashSyntax(t, script)
+}
+
+func TestManualBootstrapHandler_RenderCloudInit_DownloadOverrides(t *testing.T) {
+ t.Parallel()
+
+ cfg := &provision.AgentConfig{
+ MachineName: "test-node",
+ Cluster: provision.AgentClusterConfig{
+ CaCertBase64: "dGVzdA==",
+ ClusterDNS: "10.0.0.10",
+ Version: "v1.30.0",
+ },
+ Kubelet: provision.AgentKubeletConfig{
+ ApiServer: "https://api-server:6443",
+ BootstrapToken: "abc123.0123456789abcdef",
+ },
+ }
+
+ h := &manualBootstrapHandler{
+ logger: discardLogger(),
+ agentURL: "https://mirror.example.com/agent.tar.gz",
+ }
+
+ output, err := h.renderCloudInit(cfg)
+ require.NoError(t, err)
+
+ // The override must be exported in runcmd before invoking the install
+ // script.
+ require.Contains(t, output, "export AGENT_URL='https://mirror.example.com/agent.tar.gz'")
+
+ exportIdx := strings.Index(output, "export AGENT_URL=")
+ runIdx := strings.Index(output, "bash /usr/local/bin/unbounded-agent-install.sh")
+ require.Greater(t, exportIdx, 0)
+ require.Greater(t, runIdx, exportIdx)
+}
+
+// requireValidBashSyntax shells out to `bash -n` to syntax-check a script.
+// It skips the test if bash is not available in the test environment.
+func requireValidBashSyntax(t *testing.T, script string) {
+ t.Helper()
+
+ bashPath, err := exec.LookPath("bash")
+ if err != nil {
+ t.Skipf("bash not found in PATH: %v", err)
+ }
+
+ f, err := os.CreateTemp(t.TempDir(), "bootstrap-*.sh")
+ require.NoError(t, err)
+
+ _, err = f.WriteString(script)
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ cmd := exec.Command(bashPath, "-n", f.Name())
+
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, "bash -n failed: %s", string(out))
+}
diff --git a/docs/content/guides/agent.md b/docs/content/guides/agent.md
index 077addfe3..a7d4ba2d1 100644
--- a/docs/content/guides/agent.md
+++ b/docs/content/guides/agent.md
@@ -88,6 +88,44 @@ kubectl unbounded machine manual-bootstrap my-node --site mysite > bootstrap.sh
```
{{< /callout >}}
+### Customizing the agent download
+
+By default the bootstrap script downloads the latest published
+`unbounded-agent` release directly from GitHub. This means new releases are
+picked up automatically without editing scripts or docs. The download can be
+customized when generating the bootstrap payload or at runtime via
+environment variables:
+
+- `--agent-version` / `AGENT_VERSION`: pin to a specific release tag instead
+ of tracking latest. Example: `--agent-version v0.0.10`.
+- `--agent-base-url` / `AGENT_BASE_URL`: point at a self-hosted mirror of the
+ release assets. The layout under the base URL must match the GitHub
+ releases layout (`/latest/download/` and
+ `/download//`). Useful for air-gapped environments.
+- `--agent-url` / `AGENT_URL`: fully qualified download URL for the agent
+ tarball. Overrides the version / base URL resolution entirely.
+
+Examples:
+
+```bash
+# Pin the agent to a specific release:
+kubectl unbounded machine manual-bootstrap my-node --site mysite \
+ --agent-version v0.0.10 | ssh user@host sudo bash
+
+# Self-host the release assets under an internal mirror:
+kubectl unbounded machine manual-bootstrap my-node --site mysite \
+ --agent-base-url https://releases.internal.example.com/unbounded \
+ | ssh user@host sudo bash
+```
+
+The same overrides can also be exported on the target host before running a
+previously generated bootstrap script:
+
+```bash
+export AGENT_BASE_URL=https://releases.internal.example.com/unbounded
+bash bootstrap.sh
+```
+
A successful run looks like this (timestamps shortened for readability):
```
diff --git a/internal/provision/assets/unbounded-agent-install.sh b/internal/provision/assets/unbounded-agent-install.sh
index 54e32e53d..cf94e2bfe 100644
--- a/internal/provision/assets/unbounded-agent-install.sh
+++ b/internal/provision/assets/unbounded-agent-install.sh
@@ -11,10 +11,23 @@ set -eo pipefail
# variable, so this script only needs to validate it exists, download the
# agent, and run it.
#
-# Optional environment variables:
-# AGENT_VERSION - unbounded-agent release version (default: "v0.0.10")
-# AGENT_URL - override the download URL for the unbounded-agent tarball
-# AGENT_DEBUG - enable debug mode for unbounded-agent (e.g. "1", "true", "yes")
+# Optional environment variables (download customization):
+# AGENT_VERSION - pin to a specific unbounded-agent release tag
+# (e.g. "v0.0.10"). When unset (default) the script
+# downloads the latest published GitHub release so
+# new releases are picked up automatically.
+# AGENT_BASE_URL - base URL for release downloads. Defaults to
+# "https://github.com/Azure/unbounded/releases".
+# Set this to self-host or mirror the release assets
+# (the layout under the base URL must match the
+# GitHub releases layout:
+# /latest/download/ and
+# /download//).
+# AGENT_URL - fully qualified download URL for the agent tarball.
+# When set it overrides AGENT_VERSION and
+# AGENT_BASE_URL entirely.
+# AGENT_DEBUG - enable debug mode for unbounded-agent
+# (e.g. "1", "true", "yes").
if [ -z "${UNBOUNDED_AGENT_CONFIG_FILE}" ]; then
echo "UNBOUNDED_AGENT_CONFIG_FILE is not set" >&2
@@ -29,7 +42,8 @@ fi
# ---------------------------------------------------------------------------
# Download and run the agent.
# ---------------------------------------------------------------------------
-AGENT_VERSION="${AGENT_VERSION:-v0.0.10}"
+AGENT_VERSION="${AGENT_VERSION:-}"
+AGENT_BASE_URL="${AGENT_BASE_URL:-https://github.com/Azure/unbounded/releases}"
arch="$(uname -m)"
case "$arch" in
@@ -39,11 +53,22 @@ case "$arch" in
esac
if [ -z "${AGENT_URL}" ]; then
- AGENT_URL="https://github.com/Azure/unbounded/releases/download/${AGENT_VERSION}/unbounded-agent-linux-${arch}.tar.gz"
+ if [ -z "${AGENT_VERSION}" ]; then
+ # Track the latest published release. GitHub's "latest/download"
+ # endpoint auto-redirects to the newest release asset, so a new
+ # release is picked up without editing this script.
+ AGENT_URL="${AGENT_BASE_URL}/latest/download/unbounded-agent-linux-${arch}.tar.gz"
+ _version_desc="latest"
+ else
+ AGENT_URL="${AGENT_BASE_URL}/download/${AGENT_VERSION}/unbounded-agent-linux-${arch}.tar.gz"
+ _version_desc="${AGENT_VERSION}"
+ fi
+else
+ _version_desc="${AGENT_VERSION:-custom}"
fi
AGENT_BIN="/usr/local/bin/unbounded-agent"
-echo "Downloading unbounded-agent ${AGENT_VERSION} for ${arch}..."
+echo "Downloading unbounded-agent ${_version_desc} for ${arch} from ${AGENT_URL}..."
curl -fsSL "${AGENT_URL}" | tar -xz -C /usr/local/bin unbounded-agent
chmod +x "${AGENT_BIN}"
diff --git a/internal/provision/script_test.go b/internal/provision/script_test.go
index 28e175bd5..735b71793 100644
--- a/internal/provision/script_test.go
+++ b/internal/provision/script_test.go
@@ -17,10 +17,27 @@ func TestUnboundedAgentInstallScript(t *testing.T) {
require.NotEmpty(t, script)
require.Contains(t, script, "#!/bin/bash")
require.Contains(t, script, "unbounded-agent")
- require.Contains(t, script, `if [ -z "${AGENT_URL}" ]; then
- AGENT_URL="https://github.com/Azure/unbounded/releases/download/${AGENT_VERSION}/unbounded-agent-linux-${arch}.tar.gz"
-fi`)
- require.Contains(t, script, `curl -fsSL "${AGENT_URL}" | tar -xz -C /usr/local/bin unbounded-agent`)
+
+ // The install script must support the documented download-override
+ // environment variables.
+ require.Contains(t, script, "AGENT_VERSION")
+ require.Contains(t, script, "AGENT_BASE_URL")
+ require.Contains(t, script, "AGENT_URL")
+
+ // The default base URL must point at GitHub releases so that a fresh
+ // install works out of the box.
+ require.Contains(t, script, "https://github.com/Azure/unbounded/releases")
+
+ // The default (unpinned) download URL must use GitHub's /latest/download/
+ // redirect so a new release is picked up without editing this script.
+ require.Contains(t, script, "/latest/download/unbounded-agent-linux-")
+
+ // Pinned downloads must use the /download// layout.
+ require.Contains(t, script, "/download/${AGENT_VERSION}/unbounded-agent-linux-")
+
+ // The script must not hardcode a specific release tag as a fallback
+ // default, so that "latest" is used when AGENT_VERSION is unset.
+ require.NotContains(t, script, "AGENT_VERSION:-v0.0.10")
}
func TestUnboundedAgentUninstallScript(t *testing.T) {