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) {