Skip to content

Commit 8ed0e35

Browse files
feat(bootstrap): fold enterprise mirror follow-ups
Salvage the apm-review-panel follow-up delta that landed after PR #1733 was squash-merged for issue #1680. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d6fd0ee commit 8ed0e35

14 files changed

Lines changed: 201 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
of `~/.hermes/config.yaml` (written atomically with `0o600` perms, preserving
1818
unrelated config keys and refusing to overwrite a malformed file). `HERMES_HOME`
1919
overrides the Hermes home directory. See the [Hermes integration guide](https://microsoft.github.io/apm/integrations/hermes/).
20-
- Enterprise bootstrap mirror mode lets `install.sh`, `install.ps1`, and `apm self-update` use internal release, installer, and PyPI mirrors with fail-closed public fallback, and closes #1680. (#1733)
20+
- Enterprise bootstrap mirror mode lets `install.sh`, `install.ps1`, and `apm self-update` use `APM_RELEASE_METADATA_URL`, `APM_RELEASE_BASE_URL`, `APM_INSTALLER_BASE_URL`, `APM_PYPI_INDEX_URL`, and `APM_NO_DIRECT_FALLBACK` for internal release, installer, and PyPI mirrors with fail-closed public fallback, and closes #1680. (#1733)
2121

2222
### Fixed
2323

docs/src/content/docs/getting-started/installation.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ jobs:
102102

103103
### Enterprise bootstrap mirror mode
104104

105-
Mirror mode lets locked-down workstations install and update APM without direct public egress:
105+
APM ships native enterprise mirror support: five env vars route bootstrap traffic through internal hosts, with fail-closed mode ensuring no public fallback.
106106

107107
```bash
108108
export APM_INSTALLER_BASE_URL="https://artifactory.mycorp.example/generic/apm-install"
@@ -144,7 +144,7 @@ apm-releases/
144144

145145
#### What fail-closed does and does not cover
146146

147-
Fail-closed scoping keys off the public `github.com` default. The guard only blocks egress when the resolved host would be public GitHub (`github.com` / `api.github.com`), `aka.ms`, or public PyPI. It does **not** suppress egress to a custom `GITHUB_URL`: if you set a GHES host (for example `GITHUB_URL=https://github.corp.com`) together with `APM_NO_DIRECT_FALLBACK=1` and no release mirror, the installer still reaches that GHES host. This is intentional coexistence with GHES, but "no direct fallback" should not be read as "zero egress" -- it means "no fallback to public hosts". For true zero-egress, set the `APM_RELEASE_METADATA_URL` / `APM_RELEASE_BASE_URL` / `APM_INSTALLER_BASE_URL` / `APM_PYPI_INDEX_URL` mirrors so every request resolves to your internal hosts. The GitHub token is attached only when the request targets the canonical GitHub / configured GHES host; it is never sent to a mirror host.
147+
Fail-closed scoping keys off the public `github.com` default. The guard only blocks egress when the resolved host would be public GitHub (`github.com` / `api.github.com`), `aka.ms`, or public PyPI. It does **not** suppress egress to a custom `GITHUB_URL`: if you set a GHES host (for example `GITHUB_URL=https://github.corp.com`) together with `APM_NO_DIRECT_FALLBACK=1` and no release mirror, the installer still reaches that GHES host. This is intentional coexistence with GHES, but "no direct fallback" should not be read as "zero egress" -- it means "no fallback to public hosts". For true zero-egress, set the `APM_RELEASE_METADATA_URL` / `APM_RELEASE_BASE_URL` / `APM_INSTALLER_BASE_URL` / `APM_PYPI_INDEX_URL` mirrors so every request resolves to your internal hosts. When `APM_RELEASE_METADATA_URL` is unset, GHES metadata requests intentionally use the resolved GitHub token for that host; mirror metadata requests never receive it. The GitHub token is attached only when the request targets the canonical GitHub / configured GHES host, never a mirror host.
148148

149149
Homebrew and Scoop mirror support is docs-only in this v0: mirror the tap or bucket with your package manager's normal enterprise controls, but the APM env vars above do not rewrite Homebrew or Scoop internals.
150150

@@ -187,13 +187,17 @@ chmod +x .apm-mirror-smoke/bin/curl .apm-mirror-smoke/bin/pip .apm-mirror-smoke/
187187
python3 -m http.server 8765 --directory .apm-mirror-smoke/mirror > .apm-mirror-smoke/server.log 2>&1 &
188188
server_pid=$!
189189
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
190+
set +e
190191
PATH="$PWD/.apm-mirror-smoke/bin:$PATH" \
191192
APM_INSTALLER_BASE_URL="http://127.0.0.1:8765/apm-install" \
192193
APM_RELEASE_METADATA_URL="http://127.0.0.1:8765/apm-releases/latest.json" \
193194
APM_RELEASE_BASE_URL="http://127.0.0.1:8765/apm-releases" \
194195
APM_PYPI_INDEX_URL="http://127.0.0.1:8765/pypi/simple" \
195196
APM_NO_DIRECT_FALLBACK=1 \
196-
sh .apm-mirror-smoke/mirror/apm-install/install.sh || test $? -eq 1
197+
sh .apm-mirror-smoke/mirror/apm-install/install.sh
198+
status=$?
199+
set -e
200+
test "$status" -ne 0
197201
```
198202

199203
For `apm self-update`, run `apm self-update --check` with the same env vars and verify your proxy, firewall, or CI egress logs show only the mirror host. Use a disposable runner for a full `apm self-update` because it executes the mirrored installer.

docs/src/content/docs/reference/cli/self-update.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Some package-manager distributions (for example, Homebrew) disable self-update a
4141
|----------|---------|--------|
4242
| `APM_RELEASE_METADATA_URL` | _(unset)_ | Exact URL for mirrored release metadata, usually a static `latest.json` with at least `{"tag_name":"vX.Y.Z"}`. Overrides GitHub release metadata lookup. |
4343
| `APM_INSTALLER_BASE_URL` | _(unset)_ | Base URL containing `install.sh` and `install.ps1`. `apm self-update` downloads the platform script from this base. |
44-
| `APM_RELEASE_BASE_URL` | _(unset)_ | Base URL containing release assets at `{base}/{tag}/{asset}`. The downloaded installer subprocess inherits this value. |
44+
| `APM_RELEASE_BASE_URL` | _(unset)_ | Base URL containing release assets at `{base}/{tag}/{asset}`. Used when self-update runs the installer to fetch binary archives. |
4545
| `APM_PYPI_INDEX_URL` | _(unset)_ | PyPI-compatible index used by installer pip fallback. |
4646
| `APM_NO_DIRECT_FALLBACK` | _(unset)_ | Set to `1` to fail closed instead of using public GitHub, `aka.ms`, or PyPI fallback. |
4747
| `GITHUB_URL` | `https://github.com` | Legacy GitHub/GHES base URL. Still supported when mirror env vars are not set. |
@@ -61,9 +61,7 @@ apm self-update --check
6161
apm self-update
6262
```
6363

64-
With `APM_NO_DIRECT_FALLBACK=1`, missing or unreachable mirror settings are hard failures with a non-zero exit. APM does not fall back to public GitHub, `aka.ms`, or PyPI in that mode.
65-
66-
Fail-closed scoping keys off the public `github.com` default: it blocks fallback to public hosts, not all egress. A custom `GITHUB_URL` (a GHES host) combined with `APM_NO_DIRECT_FALLBACK=1` and no release mirror still egresses to that GHES host -- this is intentional GHES coexistence. For zero public egress set the `APM_RELEASE_METADATA_URL` / `APM_RELEASE_BASE_URL` / `APM_INSTALLER_BASE_URL` / `APM_PYPI_INDEX_URL` mirrors. The GitHub token is sent only to the canonical GitHub / configured GHES host, never to a mirror host.
64+
With `APM_NO_DIRECT_FALLBACK=1`, missing or unreachable mirror settings are hard failures with a non-zero exit. For the full fail-closed scope, GHES token boundary, and no-egress smoke recipe, see [Enterprise bootstrap mirror mode](../../../getting-started/installation/#enterprise-bootstrap-mirror-mode).
6765

6866
## Options
6967

install.ps1

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,9 +479,10 @@ if ($pinnedVersion) {
479479
exit 1
480480
}
481481
$latestUri = Get-ReleaseMetadataUri
482+
$headers = if ($releaseMetadataUrl) { @{} } else { Get-AuthHeader }
482483
$metadataError = $null
483484
try {
484-
$release = Invoke-RestMethod -Uri $latestUri
485+
$release = Invoke-GitHubJson -Uri $latestUri -Headers $headers
485486
} catch {
486487
$metadataError = $_
487488
}

install.sh

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,15 @@ is_truthy() {
9797
}
9898

9999
is_public_github_url() {
100-
[ "${GITHUB_URL%/}" = "https://github.com" ]
100+
[ "${GITHUB_URL:-https://github.com}" = "https://github.com" ] || [ "${GITHUB_URL%/}" = "https://github.com" ]
101+
}
102+
103+
fail_closed_error() {
104+
_mirror_var="$1"
105+
shift
106+
echo -e "${RED}Error: APM_NO_DIRECT_FALLBACK is set, but $_mirror_var is not configured.${NC}"
107+
echo "$*"
108+
exit 1
101109
}
102110

103111
join_url_path() {
@@ -190,9 +198,7 @@ try_pip_installation() {
190198
PIP_INSTALL_OK=0
191199
$PIP_CMD install --user --index-url "$APM_PYPI_INDEX_URL" apm-cli || PIP_INSTALL_OK=$?
192200
elif is_truthy "$APM_NO_DIRECT_FALLBACK"; then
193-
echo -e "${RED}Error: APM_NO_DIRECT_FALLBACK is set, but APM_PYPI_INDEX_URL is not configured.${NC}"
194-
echo "Set APM_PYPI_INDEX_URL to your internal PyPI proxy before using pip fallback."
195-
return 1
201+
fail_closed_error APM_PYPI_INDEX_URL "Set APM_PYPI_INDEX_URL to your internal PyPI proxy before using pip fallback."
196202
else
197203
PIP_INSTALL_OK=0
198204
$PIP_CMD install --user apm-cli || PIP_INSTALL_OK=$?
@@ -297,9 +303,7 @@ fi
297303
if [ -n "$VERSION" ]; then
298304
TAG_NAME="$VERSION"
299305
if is_truthy "$APM_NO_DIRECT_FALLBACK" && [ -z "$APM_RELEASE_BASE_URL" ] && is_public_github_url; then
300-
echo -e "${RED}Error: APM_NO_DIRECT_FALLBACK is set, but APM_RELEASE_BASE_URL is not configured.${NC}"
301-
echo "Set APM_RELEASE_BASE_URL to a mirror containing $TAG_NAME/$DOWNLOAD_BINARY."
302-
exit 1
306+
fail_closed_error APM_RELEASE_BASE_URL "Set APM_RELEASE_BASE_URL to a mirror containing $TAG_NAME/$DOWNLOAD_BINARY."
303307
fi
304308
DOWNLOAD_URL=$(release_asset_url "$TAG_NAME" "$DOWNLOAD_BINARY")
305309
echo -e "${GREEN}Version: $TAG_NAME${NC}"
@@ -311,9 +315,7 @@ if [ -z "$TAG_NAME" ]; then
311315
echo -e "${YELLOW}Fetching latest release information...${NC}"
312316

313317
if is_truthy "$APM_NO_DIRECT_FALLBACK" && [ -z "$APM_RELEASE_METADATA_URL" ] && is_public_github_url; then
314-
echo -e "${RED}Error: APM_NO_DIRECT_FALLBACK is set, but APM_RELEASE_METADATA_URL is not configured.${NC}"
315-
echo "Set APM_RELEASE_METADATA_URL to mirrored latest.json, or set VERSION to a pinned release."
316-
exit 1
318+
fail_closed_error APM_RELEASE_METADATA_URL "Set APM_RELEASE_METADATA_URL to mirrored latest.json, or set VERSION to a pinned release."
317319
fi
318320

319321
LATEST_RELEASE_URL=$(release_metadata_url)
@@ -397,9 +399,7 @@ fi
397399
# Use grep -o to extract just the matching portion (handles single-line JSON)
398400
TAG_NAME=$(echo "$LATEST_RELEASE" | grep -o '"tag_name": *"[^"]*"' | awk -F'"' '{print $4}')
399401
if is_truthy "$APM_NO_DIRECT_FALLBACK" && [ -z "$APM_RELEASE_BASE_URL" ] && is_public_github_url; then
400-
echo -e "${RED}Error: APM_NO_DIRECT_FALLBACK is set, but APM_RELEASE_BASE_URL is not configured.${NC}"
401-
echo "Set APM_RELEASE_BASE_URL to a mirror containing $TAG_NAME/$DOWNLOAD_BINARY."
402-
exit 1
402+
fail_closed_error APM_RELEASE_BASE_URL "Set APM_RELEASE_BASE_URL to a mirror containing $TAG_NAME/$DOWNLOAD_BINARY."
403403
fi
404404
DOWNLOAD_URL=$(release_asset_url "$TAG_NAME" "$DOWNLOAD_BINARY")
405405

packages/apm-guide/.apm/skills/apm-usage/installation.md

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,7 @@ irm https://aka.ms/apm-windows | iex
6969

7070
## Enterprise bootstrap mirrors
7171

72-
Use these env vars to install and update APM through an internal mirror and fail closed when a public fallback would be required:
73-
74-
| Variable | Purpose |
75-
|----------|---------|
76-
| `APM_INSTALLER_BASE_URL` | Base URL containing `install.sh` and `install.ps1`. |
77-
| `APM_RELEASE_METADATA_URL` | Exact URL for mirrored `latest.json` release metadata. |
78-
| `APM_RELEASE_BASE_URL` | Base URL for release assets at `{base}/{tag}/{asset}`. |
79-
| `APM_PYPI_INDEX_URL` | PyPI proxy used by installer pip fallback. |
80-
| `APM_NO_DIRECT_FALLBACK` | Set to `1` to block public GitHub, `aka.ms`, and PyPI fallback. |
72+
Set `APM_INSTALLER_BASE_URL`, `APM_RELEASE_METADATA_URL`, `APM_RELEASE_BASE_URL`, `APM_PYPI_INDEX_URL`, and `APM_NO_DIRECT_FALLBACK=1` to install and update APM through an internal mirror while failing closed on public fallback. The canonical variable table, GHES scoping note, and no-egress smoke recipe live in [installation.md](https://github.com/microsoft/apm/blob/main/docs/src/content/docs/getting-started/installation.md#enterprise-bootstrap-mirror-mode).
8173

8274
```bash
8375
export APM_INSTALLER_BASE_URL="https://artifactory.mycorp.example/generic/apm-install"
@@ -91,10 +83,6 @@ apm self-update --check
9183

9284
For dependency installs after bootstrap, keep using `PROXY_REGISTRY_URL` and `PROXY_REGISTRY_ONLY=1`. Homebrew and Scoop mirroring is package-manager documentation only in v0; these env vars do not rewrite Homebrew or Scoop internals.
9385

94-
Fail-closed scoping keys off the public `github.com` default: it blocks fallback to public hosts, not all egress. A custom `GITHUB_URL` (GHES host) plus `APM_NO_DIRECT_FALLBACK=1` and no release mirror still reaches that GHES host. Set the four mirror URLs for zero public egress. The GitHub token is attached only to the canonical GitHub / configured GHES host, never to a mirror host (symmetric across `install.sh` and `install.ps1`).
95-
96-
No-egress smoke test: run the installer on a disposable runner with `curl` and `pip` wrappers (or an egress proxy) that deny `github.com`, `api.github.com`, `aka.ms`, `pypi.org`, `pythonhosted.org`, Homebrew, and Scoop upstreams. Wrapping `pip` keeps the proof honest about the PyPI fallback path. With all mirror env vars set, the only allowed outbound host should be your mirror. Run `apm self-update --check` under the same env vars and confirm proxy logs show only the mirror host.
97-
9886
## Troubleshooting
9987

10088
- **macOS/Linux "command not found":** ensure your install directory (default `/usr/local/bin`) is in `$PATH`.

src/apm_cli/bootstrap_mirror.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import os
44

5+
from apm_cli.utils.path_security import PathTraversalError, validate_path_segments
6+
57
APM_RELEASE_BASE_URL = "APM_RELEASE_BASE_URL"
68
APM_RELEASE_METADATA_URL = "APM_RELEASE_METADATA_URL"
79
APM_INSTALLER_BASE_URL = "APM_INSTALLER_BASE_URL"
810
APM_PYPI_INDEX_URL = "APM_PYPI_INDEX_URL"
911
APM_NO_DIRECT_FALLBACK = "APM_NO_DIRECT_FALLBACK"
10-
VERSION = "VERSION"
12+
VERSION_ENV_VAR = "VERSION"
1113

1214
_PUBLIC_GITHUB_URL = "https://github.com"
1315
_TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -30,8 +32,18 @@ def no_direct_fallback_enabled() -> bool:
3032

3133

3234
def append_url_path(base_url: str, *parts: str) -> str:
33-
"""Join a base URL and path parts without introducing double slashes."""
34-
cleaned = [part.strip("/") for part in parts if part]
35+
"""Join a base URL and path parts without unsafe dot segments."""
36+
cleaned: list[str] = []
37+
for part in parts:
38+
if not part:
39+
continue
40+
segment = part.strip("/")
41+
try:
42+
validate_path_segments(segment, context="URL path part")
43+
except PathTraversalError as exc:
44+
raise ValueError("URL path parts must not contain dot segments") from exc
45+
if segment:
46+
cleaned.append(segment)
3547
if not cleaned:
3648
return base_url.rstrip("/")
3749
return "/".join([base_url.rstrip("/"), *cleaned])
@@ -68,7 +80,7 @@ def release_metadata_public_lookup_blocked(github_url: str | None = None) -> boo
6880
no_direct_fallback_enabled()
6981
and is_public_github_url(github_url)
7082
and get_release_metadata_url() is None
71-
and not os.environ.get(VERSION, "").strip()
83+
and not os.environ.get(VERSION_ENV_VAR, "").strip()
7284
)
7385

7486

src/apm_cli/commands/self_update.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,15 @@ def _get_update_installer_suffix() -> str:
7171

7272

7373
def _get_manual_update_command() -> str:
74-
"""Return the manual update command for the current platform."""
74+
"""Return the manual update action for the current platform."""
7575
if _is_windows_platform():
7676
if get_installer_base_url() is not None:
7777
installer_url = "$env:APM_INSTALLER_BASE_URL/install.ps1"
7878
else:
7979
try:
8080
installer_url = _get_update_installer_url()
8181
except RuntimeError:
82-
installer_url = "$env:APM_INSTALLER_BASE_URL/install.ps1"
82+
return "Set APM_INSTALLER_BASE_URL and re-run 'apm self-update'."
8383
return f"powershell -ExecutionPolicy Bypass -c 'irm \"{installer_url}\" | iex'"
8484

8585
if get_installer_base_url() is not None:
@@ -88,7 +88,7 @@ def _get_manual_update_command() -> str:
8888
try:
8989
installer_url = _get_update_installer_url()
9090
except RuntimeError:
91-
installer_url = "$APM_INSTALLER_BASE_URL/install.sh"
91+
return "Set APM_INSTALLER_BASE_URL and re-run 'apm self-update'."
9292
return f'curl -sSL "{installer_url}" | sh'
9393

9494

@@ -112,7 +112,18 @@ def _get_installer_run_command(script_path: str) -> list[str]:
112112
return [shell_path, script_path]
113113

114114

115-
@click.command(name="self-update", help="Update the APM CLI binary itself to the latest version")
115+
@click.command(
116+
name="self-update",
117+
help=(
118+
"Update the APM CLI binary itself to the latest version.\n\n"
119+
"Environment variables for enterprise bootstrap mirrors:\n"
120+
" APM_RELEASE_METADATA_URL Mirror latest.json release metadata.\n"
121+
" APM_RELEASE_BASE_URL Mirror release assets at {base}/{tag}/{asset}.\n"
122+
" APM_INSTALLER_BASE_URL Mirror install.sh/install.ps1 for self-update.\n"
123+
" APM_PYPI_INDEX_URL PyPI mirror for installer pip fallback.\n"
124+
" APM_NO_DIRECT_FALLBACK Set to 1 to fail closed on public fallback.\n"
125+
),
126+
)
116127
@click.option("--check", is_flag=True, help="Only check for updates without installing")
117128
def self_update(check):
118129
"""Update APM CLI to the latest version (like npm update -g npm).
@@ -174,7 +185,7 @@ def self_update(check):
174185
)
175186
else:
176187
logger.error("Unable to fetch latest version from remote")
177-
logger.progress("Please check your internet connection or try again later")
188+
logger.info("Check your internet connection or try again later.")
178189
sys.exit(1)
179190

180191
from ..utils.version_checker import is_newer_version
@@ -259,12 +270,12 @@ def self_update(check):
259270

260271
except ImportError:
261272
logger.error("'requests' library not available")
262-
logger.progress("Please update manually using:")
273+
logger.info("Update manually using:")
263274
click.echo(f" {_get_manual_update_command()}")
264275
sys.exit(1)
265276
except Exception as e:
266277
logger.error(f"Update failed: {e}")
267-
logger.progress("Please update manually using:")
278+
logger.info("Update manually using:")
268279
click.echo(f" {_get_manual_update_command()}")
269280
sys.exit(1)
270281

src/apm_cli/core/auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,11 @@ def __init__(
192192
self,
193193
token_manager: GitHubTokenManager | None = None,
194194
logger: object | None = None,
195+
*,
196+
allow_external_fallback: bool = True,
195197
):
196198
self._token_manager = token_manager or GitHubTokenManager()
199+
self._allow_external_fallback = allow_external_fallback
197200
self._cache: dict[tuple, AuthContext] = {}
198201
self._lock = threading.Lock()
199202
# F2/F3 #852: optional logger lets the install command route the
@@ -897,6 +900,9 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No
897900
source = self._identify_env_source(purpose)
898901
return token, source, "basic"
899902

903+
if not self._allow_external_fallback:
904+
return None, "none", "basic"
905+
900906
# 3. gh CLI active account (eligibility gated inside the call;
901907
# unsupported hosts return None instantly without a subprocess)
902908
gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host)

0 commit comments

Comments
 (0)