Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6f4f05a4 | ||
|
|
6911b9de27 | ||
|
|
0b3b100a91 | ||
|
|
ba88323feb | ||
|
|
5f38e0d0d2 | ||
|
|
2f5305eeb1 | ||
|
|
bc23fc2dee | ||
|
|
cf9b5eab3a | ||
|
|
a8b83ca63a | ||
|
|
964ab993c8 | ||
|
|
3324e3d6fe | ||
|
|
97e6a3e15a | ||
|
|
f26388969b | ||
|
|
b7cdf02859 | ||
|
|
9ad8b99d3c | ||
|
|
a000d64577 | ||
|
|
02c8616512 | ||
|
|
7dbf568696 | ||
|
|
31c0a918e6 | ||
|
|
f061ddb9a4 | ||
|
|
6192b7c1e8 | ||
|
|
3583bee31e | ||
|
|
fe7642ee2c | ||
|
|
f924040f9e | ||
|
|
3bc0e9fc3b | ||
|
|
8ec2cfece6 | ||
|
|
4694ae60c2 | ||
|
|
b527c82400 | ||
|
|
01eab26a48 | ||
|
|
1bf9c1c959 | ||
|
|
90b10d48d2 | ||
|
|
1df7dfc0a8 | ||
|
|
1afddc0ba9 | ||
|
|
c82b63f48a | ||
|
|
634d1f5a7e | ||
|
|
7fdd5dc277 | ||
|
|
9d1630eb97 | ||
|
|
8e6983cf53 | ||
|
|
02d88cb91d | ||
|
|
1c337f9198 | ||
|
|
92b27a8e32 | ||
|
|
4acc36187e | ||
|
|
2dea30f4fb | ||
|
|
664697b151 | ||
|
|
e42261bac8 | ||
|
|
60dad7b6b6 | ||
|
|
e8bbe90327 | ||
|
|
7e2800989f | ||
|
|
95991c1309 | ||
|
|
b6e59154cc | ||
|
|
bd0a52269b | ||
|
|
8787f39a38 | ||
|
|
7462cb05ec | ||
|
|
e3c81fd304 | ||
|
|
50f3d51946 | ||
|
|
1e85b4d05f | ||
|
|
cb9e51f08d | ||
|
|
74deabd630 | ||
|
|
c2aa9c3be2 | ||
|
|
36a6c869c9 | ||
|
|
114f463bf5 | ||
|
|
2a46f34fca | ||
|
|
fe889a7f16 | ||
|
|
fffc6492f1 | ||
|
|
0f638c7c4e | ||
|
|
a068ce27bc | ||
|
|
5d03bea544 | ||
|
|
d8c5b501f3 | ||
|
|
ebaafd8cfc | ||
|
|
55ed2d17d0 | ||
|
|
48c60ef89c | ||
|
|
20a25fc518 | ||
|
|
086abd5713 | ||
|
|
7c004fed46 | ||
|
|
45e55b5f7c | ||
|
|
83d63e756d | ||
|
|
cc973c8291 | ||
|
|
3ec100e06c | ||
|
|
a19643e0e8 | ||
|
|
32a4d345f2 | ||
|
|
229dc2cefc | ||
|
|
41010a9c50 | ||
|
|
737a608dd5 | ||
|
|
209b8855a4 | ||
|
|
3a4e298849 | ||
|
|
1118cfa458 | ||
|
|
d4a14d2ded | ||
|
|
4532412869 | ||
|
|
e5b73d8fa5 |
No files matched your search
@@ -66,7 +66,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -84,6 +90,9 @@ on:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app-preview.yml"
|
||||
# The app embeds the agent's sources for its local image build, and the
|
||||
# agent's own tests run in the `test` job below.
|
||||
- "container/triple-c-agent/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -269,7 +278,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -280,10 +289,14 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-pipewire \
|
||||
gstreamer1.0-pulseaudio \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
@@ -328,6 +341,16 @@ jobs:
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo test --locked
|
||||
|
||||
# The in-container agent is its own crate (container/triple-c-agent): a
|
||||
# real server on loopback, exercised over HTTP/WebSocket as the current
|
||||
# user. Its root-only test is #[ignore]d. The Docker-parity test against
|
||||
# a real container lives in the app (host::agent::live_tests, ignored).
|
||||
- name: Agent tests (cargo test)
|
||||
working-directory: ./container/triple-c-agent
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo test --locked
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, create-release]
|
||||
@@ -351,7 +374,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -374,10 +397,14 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-pipewire \
|
||||
gstreamer1.0-pulseaudio \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
name: Build App
|
||||
|
||||
# **Releases are cut by hand.** This runs only when someone dispatches it
|
||||
# (Actions -> Build App -> Run workflow). Merging to main does not release:
|
||||
# main can carry finished-but-dormant work (a feature landing in phases) and
|
||||
# nothing reaches users until a release is cut on purpose.
|
||||
#
|
||||
# Dispatched on `main` it releases: Gitea and GitHub releases, the version tag,
|
||||
# the Linux update channel, and then the container images' `:latest` (see the
|
||||
# create-tag job). Dispatched on any other branch it is a dry run that builds,
|
||||
# signs and verifies all three platforms and publishes nothing, which is how to
|
||||
# exercise Windows signing before a merge. Every publishing step and job
|
||||
# carries the same `if:` (dispatch event *and* ref main); keep them in step if
|
||||
# one is ever added.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
- "scripts/windows-*.ps1"
|
||||
workflow_dispatch:
|
||||
|
||||
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
||||
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
||||
# the same three and publishes them. Six OS builds per push, one set of which
|
||||
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||
# Deliberately **not** on pull_request: build-app-preview.yml carries the PR
|
||||
# check (tests plus all three platforms), and this workflow is releases only.
|
||||
|
||||
env:
|
||||
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
|
||||
@@ -22,7 +24,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -125,7 +133,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
echo "Node.js at: $(which node)"
|
||||
@@ -147,10 +155,14 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-pipewire \
|
||||
gstreamer1.0-pulseaudio \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
@@ -252,7 +264,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
@@ -278,7 +290,7 @@ jobs:
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
@@ -330,7 +342,7 @@ jobs:
|
||||
# from the versioned release above because the updater's URL must never
|
||||
# move, and `releases/latest` does.
|
||||
- name: Publish the Linux update channel
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -419,7 +431,7 @@ jobs:
|
||||
ls -la artifacts/
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
@@ -445,7 +457,7 @@ jobs:
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
@@ -707,7 +719,7 @@ jobs:
|
||||
dir artifacts\
|
||||
|
||||
- name: Upload to Gitea release
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
shell: powershell
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -731,8 +743,9 @@ jobs:
|
||||
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
||||
Write-Host "Release $tag not found, creating"
|
||||
$body = @{
|
||||
tag_name = $tag
|
||||
name = "Triple-C v$env:VERSION (Windows)"
|
||||
tag_name = $tag
|
||||
target_commitish = $env:COMMIT_SHA
|
||||
name = "Triple-C v$env:VERSION (Windows)"
|
||||
body = "Automated build from commit $env:COMMIT_SHA"
|
||||
} | ConvertTo-Json
|
||||
$release = Invoke-RestMethod -Method Post -Headers $headers `
|
||||
@@ -768,7 +781,7 @@ jobs:
|
||||
create-tag:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -778,24 +791,56 @@ jobs:
|
||||
- name: Create version tag
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
set -euo pipefail
|
||||
TAG="v${VERSION}"
|
||||
echo "Creating tag ${TAG}..."
|
||||
|
||||
# Create annotated tag via Gitea API
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
|
||||
# Usually already there: creating the Linux release creates its tag,
|
||||
# at this commit (`target_commitish`). Create it if not, then insist
|
||||
# it points here, because the container step below builds whatever
|
||||
# the tag names.
|
||||
if ! curl -fsS -o tag.json -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}"; then
|
||||
echo "Creating tag ${TAG} at ${SHA}"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${SHA}\", \"message\": \"Release ${TAG}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags" > tag.json
|
||||
fi
|
||||
TAGGED=$(python3 -c "import json; print(json.load(open('tag.json'))['commit']['sha'])")
|
||||
if [ "${TAGGED}" != "${SHA}" ]; then
|
||||
echo "Tag ${TAG} points at ${TAGGED}, not the released commit ${SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag ${TAG} is at ${SHA}"
|
||||
|
||||
echo "Tag ${TAG} created successfully"
|
||||
# The containers' `:latest` is what every installed app pulls, so it
|
||||
# moves with an app release and at no other time (pushes to main only
|
||||
# publish `:edge` and `:<sha>`). Each container workflow builds the tag
|
||||
# it is handed, not main, so a merge landing mid-release cannot ride
|
||||
# along.
|
||||
- name: Release the container images
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for WORKFLOW in build.yml build-stt.yml; do
|
||||
echo "Dispatching ${WORKFLOW} for v${VERSION}"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"release_tag\": \"v${VERSION}\"}}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/${WORKFLOW}/dispatches"
|
||||
done
|
||||
|
||||
sync-to-github:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
|
||||
@@ -11,6 +11,12 @@ on:
|
||||
paths:
|
||||
- "stt-container/**"
|
||||
- ".gitea/workflows/build-stt.yml"
|
||||
# Dispatched by build-app.yml when a release is cut, with the release's tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
|
||||
required: false
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -22,6 +28,65 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# **`:latest` is what installed apps pull, so only a release moves it.**
|
||||
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
|
||||
# image by hand); a pull request publishes nothing. The weekly rebuild
|
||||
# and a release dispatch build a release tag and publish it as `:latest`
|
||||
# — the tag, not main, so unreleased container changes never reach users.
|
||||
- name: Pick the source and the tags to publish
|
||||
id: src
|
||||
env:
|
||||
EVENT: ${{ gitea.event_name }}
|
||||
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
|
||||
REF: ${{ gitea.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$EVENT" in
|
||||
push|pull_request)
|
||||
CHANNEL=edge
|
||||
;;
|
||||
schedule|workflow_dispatch)
|
||||
CHANNEL=latest
|
||||
# `:latest` reaches every user, so it only comes from main: a
|
||||
# dispatch from another branch, or a tag cut on one, is refused.
|
||||
# An older release tag is allowed on purpose — that is a rollback.
|
||||
if [ "$REF" != "refs/heads/main" ]; then
|
||||
echo "Refusing to publish :latest from $REF (only main)" >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --tags --force
|
||||
git fetch origin main
|
||||
if [ -z "${RELEASE_TAG:-}" ]; then
|
||||
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
fi
|
||||
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Not a release tag: '$RELEASE_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
|
||||
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "refs/tags/$RELEASE_TAG"
|
||||
echo "Building release $RELEASE_TAG"
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected event $EVENT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
SHA=$(git rev-parse HEAD)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
|
||||
echo "ghcr.io/shadowdao/triple-c-stt:$CHANNEL"
|
||||
echo "ghcr.io/shadowdao/triple-c-stt:$SHA"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -49,12 +114,10 @@ jobs:
|
||||
context: ./stt-container
|
||||
file: ./stt-container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ gitea.event_name == 'push' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-stt:latest
|
||||
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||
# A pull_request run only validates; see the tag step for what each
|
||||
# other event publishes.
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.src.outputs.tags }}
|
||||
# The gha cache is served by the runner that picks the job up, and
|
||||
# is unreachable from the job on some of ours (the home box's
|
||||
# firewalled per-job bridge; ephemeral runners). Import failures
|
||||
|
||||
@@ -13,9 +13,15 @@ on:
|
||||
- ".gitea/workflows/build.yml"
|
||||
# Weekly rebuild with no commit needed, so OS security fixes published since
|
||||
# the last push reach the image (see APT_REFRESH in container/Dockerfile).
|
||||
# Scheduled runs use the default branch, i.e. main.
|
||||
# It rebuilds the newest *release*, not main, because it moves `:latest`.
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
# Dispatched by build-app.yml when a release is cut, with the release's tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
|
||||
required: false
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -27,6 +33,65 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# **`:latest` is what installed apps pull, so only a release moves it.**
|
||||
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
|
||||
# image by hand); a pull request publishes nothing. The weekly rebuild
|
||||
# and a release dispatch build a release tag and publish it as `:latest`
|
||||
# — the tag, not main, so unreleased container changes never reach users.
|
||||
- name: Pick the source and the tags to publish
|
||||
id: src
|
||||
env:
|
||||
EVENT: ${{ gitea.event_name }}
|
||||
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
|
||||
REF: ${{ gitea.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$EVENT" in
|
||||
push|pull_request)
|
||||
CHANNEL=edge
|
||||
;;
|
||||
schedule|workflow_dispatch)
|
||||
CHANNEL=latest
|
||||
# `:latest` reaches every user, so it only comes from main: a
|
||||
# dispatch from another branch, or a tag cut on one, is refused.
|
||||
# An older release tag is allowed on purpose — that is a rollback.
|
||||
if [ "$REF" != "refs/heads/main" ]; then
|
||||
echo "Refusing to publish :latest from $REF (only main)" >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --tags --force
|
||||
git fetch origin main
|
||||
if [ -z "${RELEASE_TAG:-}" ]; then
|
||||
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
fi
|
||||
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Not a release tag: '$RELEASE_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
|
||||
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "refs/tags/$RELEASE_TAG"
|
||||
echo "Building release $RELEASE_TAG"
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected event $EVENT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
SHA=$(git rev-parse HEAD)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
|
||||
echo "ghcr.io/shadowdao/triple-c-sandbox:$CHANNEL"
|
||||
echo "ghcr.io/shadowdao/triple-c-sandbox:$SHA"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -81,16 +146,12 @@ jobs:
|
||||
context: ./container
|
||||
file: ./container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# Publish from main pushes and the weekly scheduled rebuild (which
|
||||
# runs on main); a pull_request run only validates.
|
||||
# A pull_request run only validates; see the tag step for what each
|
||||
# other event publishes.
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
APT_REFRESH=${{ steps.apt.outputs.stamp }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||
tags: ${{ steps.src.outputs.tags }}
|
||||
# `ignore-error` is what stops a cache failure failing a build that
|
||||
# already succeeded. act_runner emulates the GitHub Actions cache
|
||||
# service on the runner host's LAN address, and the `docker-container`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
app/dist/
|
||||
app/src-tauri/target/
|
||||
container/triple-c-agent/target/
|
||||
# Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the
|
||||
# tracked, reviewable form of the same information.
|
||||
app/src-tauri/permissions/autogenerated/
|
||||
@@ -19,3 +20,10 @@ screenshot_for_fix/
|
||||
|
||||
# Windows CI code signing (scripts/windows-signing-setup.ps1)
|
||||
.code-signing/
|
||||
|
||||
# OpenTofu working state (aws/terraform/*): providers, local state, locks.
|
||||
# The modules' .terraform.lock.hcl files are committed.
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
.terraform.tfstate.lock.info
|
||||
@@ -264,8 +264,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`docker/`** — Docker API layer using bollard:
|
||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
|
||||
attached exec is opened; terminal sessions and the auth bridge both go through it.
|
||||
- `exec.rs` — Docker's implementation of everything `host::ContainerTarget` does inside a
|
||||
container. `create_attached_exec_spec()` is the **single** place an attached exec is opened.
|
||||
- `image.rs` — Image build/pull with progress streaming
|
||||
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
|
||||
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
|
||||
@@ -282,6 +282,73 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
||||
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
|
||||
users have migrated.
|
||||
- **`host/`** — Where a project's container runs, and the only way into it. `ContainerTarget`
|
||||
(`Docker`, and `Agent` — reached through the in-container `triple-c-agent` by `agent.rs`, per
|
||||
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned —
|
||||
`docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
|
||||
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
|
||||
`ssm.rs` is the AWS transport to an `Agent` target: one SSM port-forward per task, run through
|
||||
AWS's `session-manager-plugin` as a child process and reopened **on the same loopback port**
|
||||
when it drops, so an `AgentEndpoint` stays valid. The `StartSession` token goes to the plugin
|
||||
through its environment, never argv, and the plugin's environment is stripped of every
|
||||
inherited `AWS_*` variable before the session's own credentials are added.
|
||||
`agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two
|
||||
variants cannot drift; an `Agent` target's token never appears in `Debug` or error text.
|
||||
**Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or
|
||||
`get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails
|
||||
the build on it. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and
|
||||
discarding a project's persistent state; the project commands keep only host-neutral steps
|
||||
(validation, status, sessions, auth bridge, syncs). `watch.rs` gives `container_watch` its
|
||||
death reports and positive-exit check. `no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer`
|
||||
fails on lifecycle calls or `ContainerTarget::docker(id)` in non-test code outside `host/` and
|
||||
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
|
||||
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
|
||||
(`DOCKER_ONLY`); shrink it, never grow it.
|
||||
- **`aws/`** — AWS connections (Settings → Hosts) and the permission check. `connection.rs`
|
||||
builds a saved `HostConnection`'s `SdkConfig` **by hand, never with
|
||||
`aws_config::defaults().load()`**: a profile is read through `ProfileFileCredentialsProvider`
|
||||
for credentials only, so the app's own `AWS_*` variables, `AWS_ENDPOINT_URL*` and a profile's
|
||||
endpoint/service settings can't redirect or re-credential a connection's signed requests.
|
||||
`identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is
|
||||
recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN
|
||||
lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …);
|
||||
`policies.rs` embeds `aws/policies/*.json` (the tier policies — the admin tier spans several
|
||||
files, `policy_files` lists them — and the permissions boundary) and the table of checks the
|
||||
simulator is asked; `permissions.rs` runs it
|
||||
(`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the
|
||||
caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests
|
||||
are the drift guard: every allowed action has a check, every check is allowed by a small IAM
|
||||
evaluator (`policy_eval.rs`, test-only; `tofu_modules/tests.rs` also asks it whether the admin
|
||||
tier allows every call `MODULE_CALLS` lists for the Terraform modules), owner-scoped checks are
|
||||
denied for someone else, admin destruction is tag-scoped, the boundary fences hold, every
|
||||
Resource is an ARN and every policy fits a 6,144-character managed policy. **Change a policy
|
||||
JSON, its checks and `aws/README.md` together.** The evaluator
|
||||
is not IAM: `live_tests.rs` (ignored; `TRIPLEC_AWS_E2E_PROFILE`, `TRIPLEC_AWS_E2E_REGION`,
|
||||
admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored
|
||||
--test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles,
|
||||
secrets and keys, and cleans them up. Re-run it after any policy change; the results are in
|
||||
`aws/README.md` ("Live verification"). Its `environment` tests apply the real modules as a role
|
||||
holding only the admin files (`cargo build` first: the runner they start is the built app
|
||||
binary), in a throwaway state bucket.
|
||||
**Environments are OpenTofu modules** (`aws/terraform/{environment,user-onboard,permissions-boundary}`;
|
||||
spec D2–D10). `tofu_modules.rs` embeds them and is the **only** place the shared names are built
|
||||
(environment, `/triple-c/environments/<env>` record, owner key — `sha256("<region>:<aws:userid>")`,
|
||||
12 hex, regional because role names are account-global — per-user roles, state bucket
|
||||
`triple-c-tfstate-<account>-<region>`, state keys); its tests pin the HCL to those names, the
|
||||
versions and tags, and `MODULE_CALLS` (every provider call) to the admin policy.
|
||||
`tofu.rs` downloads the pinned OpenTofu (SHA-256 checked **before** extraction), writes a work
|
||||
folder (`materialize`), builds tofu's environment from scratch (`child_env`: no inherited
|
||||
`AWS_*`/`TF_*`, keys only in the environment) and runs `init` + `apply|destroy` **detached
|
||||
(D10)**: the app binary re-executed with `--tofu-runner` (`main.rs` checks that first), in its
|
||||
own session, logging to `apply.log`; liveness is the runner's `apply.lock`, never a PID; closing
|
||||
the app never stops a run and the next start re-follows the log. `environments.rs` is detection
|
||||
(records + state objects + locks; records are untrusted and capped; an empty state, which
|
||||
`tofu destroy` leaves behind, is no deployment) and `ensure_state_bucket`;
|
||||
`commands/environment_commands.rs` sequences ensure_tofu → ensure_state_bucket → materialize →
|
||||
start → follow. **D2: strict tenancy gives each user their own EFS file system** (its policy
|
||||
admits only their task role) from their onboarding module; the environment keeps only the shared
|
||||
pieces. The app destroys only incomplete deployments (D6); a working environment is removed with
|
||||
`tofu destroy` from an admin bundle (D5).
|
||||
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||
@@ -313,8 +380,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
approach converges on the worst state: a 400 MB browser present with its libraries gone.
|
||||
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
|
||||
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then stays resident **as root** waiting on a root-owned
|
||||
`triple-c-keepalive` sleep (respawned if killed; SIGTERM trap so `docker stop` is immediate).
|
||||
**Never hand the container's lifetime to a process the `claude` user owns** — it used to end in
|
||||
`exec su … claude -c "exec sleep infinity"`, and any `pkill sleep` / `kill -9 -1` a Claude
|
||||
session ran killed the whole container. `container_watch.rs` (one Docker `die`-event
|
||||
subscription) is what flips a project whose container exits on its own to Stopped and emits
|
||||
`project-container-exited` with a `reason` (`exited` toasts; `found_stopped`, the first look of a run, is silent); `useProjectStatusEvents` applies it
|
||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||
- **`triple-c-agent/`** — the in-container agent (its own Rust crate; protocol in
|
||||
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`). Built static by the Dockerfile's
|
||||
`agent-build` stage, started by `entrypoint.sh` **only when a token is configured**, last, as root
|
||||
under a supervisor loop. **Its contract is Docker parity**: an `ExecSpec` must behave through the
|
||||
agent exactly as through `docker exec`. Any change to exec semantics on either side is checked by
|
||||
`host::agent::live_tests::the_agent_answers_exactly_as_docker_exec_does` (ignored; needs an image
|
||||
with the agent — see the crate README). The token never stays in the environment and never touches the
|
||||
writable layer: the entrypoint moves it to a root-only file on `/dev/shm` (tmpfs — a file in the
|
||||
writable layer would ride `docker commit` into the snapshot image) before anything runs, and the
|
||||
agent refuses a token file others can read. The agent is started from an env/cwd snapshot taken
|
||||
at the top of the entrypoint and held **in memory only** (it contains every API key in the env),
|
||||
so execs see the container's configured env, not the script's. The app treats the agent as
|
||||
untrusted: no redirects, capped bodies, timeouts on every small call (`host/agent.rs`).
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
|
||||
`triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory
|
||||
@@ -579,6 +665,24 @@ security update. Migration is the non-destructive way out; Reset is the destruct
|
||||
the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg
|
||||
ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path
|
||||
differences into 2 genuinely user-authored ones.
|
||||
- **`/workspace` is reported two levels deep, never file by file** (`SUMMARISED_ROOTS`). One
|
||||
`find -xdev` pass is piped through `awk`, which turns each depth-2 directory into one `S` entry
|
||||
sized as the sum of the files beneath it (the same number the per-file sum gave the disk gate),
|
||||
or a plain `d` if it holds no file at any depth (so empty scaffolds stay empty to the filter). A
|
||||
user's worktrees and `node_modules` made a flat walk 8.6 MB — past the exec cap, so every
|
||||
staleness check failed on start and stop — and pruning `node_modules`/`.git` by name only
|
||||
reached 6.3 MB; now the whole manifest is ~1 MB. Summaries lose nothing: below `/workspace/<x>`
|
||||
every per-path filter is vacuous, and `tar` copies a directory recursively. Three rules:
|
||||
- **A failed `awk` is an error, never an empty `/workspace`.** The pipeline prints
|
||||
`###SUMMARISED-OK` only on success and `check_manifest_complete` refuses a manifest without
|
||||
it — otherwise the swap would destroy what the walk failed to see.
|
||||
- **A summary is exempt from presence-in-the-base**, because it hides which paths are inside;
|
||||
`--skip-old-files` on restore is the per-file answer. That rests on the base shipping nothing
|
||||
under `/workspace` — pinned by the ignored live test
|
||||
`the_base_ships_nothing_under_a_summarised_root`, and logged as a warning if a base breaks it.
|
||||
- **Only `/workspace`.** The other roots stay per file: dpkg ownership and presence-in-the-base
|
||||
are exactly what make their delta honest (`/usr/local/lib/node_modules`, base content in
|
||||
`/opt`), and they are under 1 MB together.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -672,8 +776,9 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
now", the host builds the project's effective set (`global − disabled ∪ project`), then uploads
|
||||
`payload.tar` **and the app-embedded script `src/marketplace/sync.sh`** (`include_str!`, not a
|
||||
file in `container/`) to `~/.claude/triple-c/marketplace/incoming/` and runs it as `claude`,
|
||||
once the entrypoint has finished (`pgrep -x -f 'su -s /bin/bash claude -c exec sleep
|
||||
infinity'`). The script is re-uploaded on every sync rather than baked into the image, so every
|
||||
once the entrypoint has finished (`READY_PROBE`: a root `triple-c-keepalive infinity` process
|
||||
on current images, `su -s /bin/bash claude -c exec sleep infinity` on older ones — keep both).
|
||||
The script is re-uploaded on every sync rather than baked into the image, so every
|
||||
existing project always gets the version that matches the running app — `container/` is never
|
||||
touched for this feature. The script only removes files and hook entries it recorded in
|
||||
`~/.claude/triple-c/marketplace/state.json`; it must never overwrite or delete user-created
|
||||
@@ -733,6 +838,12 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
|
||||
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||
like config but is actually a live credential would have the same problem.
|
||||
- **AWS connection keys travel in `ExportedSecrets::aws_connection_keys`** (keyed by connection id),
|
||||
exported and restored like the marketplace tokens. `hosts` are **store-owned**: `update_settings`
|
||||
restores them via `restore_host_fields`, and `apply_settings_import` re-applies them through
|
||||
`StoreOwned`. `validate_imported_hosts` runs before any keychain write. The import preview
|
||||
discloses AWS connections, key counts, overwrites (naming the local connection), failed key
|
||||
checks and removals.
|
||||
- **Marketplace account tokens travel in `ExportedSecrets`, not in `AppSettings`.** `Token` and
|
||||
`GhContainer` accounts' tokens live in the keychain (`triple-c-marketplace-account-<id>`), so
|
||||
they follow the same "carve out of the keychain, restore before the settings replace, only
|
||||
@@ -852,6 +963,25 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
|
||||
|
||||
## Packaging
|
||||
|
||||
### Releases are cut by hand
|
||||
|
||||
**Merging to `main` does not release anything users get.** A release is cut by dispatching
|
||||
`build-app.yml` on `main` (Actions -> Build App -> Run workflow). That one run publishes the Gitea
|
||||
and GitHub releases for all three platforms, the `vX.Y.Z` tag pinned to the dispatched commit, and
|
||||
the Linux update channel. Its last step dispatches `build.yml` and `build-stt.yml` with the new tag,
|
||||
and those rebuild that tag and move `:latest`, which is what installed apps pull.
|
||||
|
||||
- Dispatching `build-app.yml` on any other branch is a **dry run**. It builds, signs and verifies,
|
||||
and publishes nothing. Every publishing step and job is gated on
|
||||
`gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'`, so give any new
|
||||
publishing step the same `if:`.
|
||||
- A push to `main` that touches `container/` or `stt-container/` publishes only `:edge` and
|
||||
`:<sha>`, never `:latest`. The weekly scheduled sandbox rebuild (OS security fixes) rebuilds the
|
||||
**newest release tag**, not main.
|
||||
- This exists so that main can carry dormant work (a feature landing in phases) without each
|
||||
merge shipping. Until the gate, every merge to main touching `app/` released, and every merge
|
||||
touching `container/` moved `:latest`.
|
||||
|
||||
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
|
||||
build and publish for an audience the AppImage already serves, and neither could self-update. The
|
||||
@@ -897,8 +1027,8 @@ quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml
|
||||
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
|
||||
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
|
||||
secret available there is available to whoever can push a branch. To exercise signing before a
|
||||
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
|
||||
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
|
||||
merge, dispatch `build-app.yml` on the branch. Off `main` it builds, signs and verifies
|
||||
without releasing (see Releases above).
|
||||
|
||||
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
|
||||
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
|
||||
@@ -931,8 +1061,8 @@ app"), never by the preview workflow, never echoed, and never on a command line.
|
||||
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
|
||||
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
|
||||
repo can reach the secrets through a workflow file, so repo write access is the boundary.
|
||||
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
|
||||
release only ever comes from a merged, visible change. The
|
||||
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), and only a
|
||||
dispatch on `main` publishes, so a signed release only ever comes from a merged, visible change. The
|
||||
Azure side should hold the rest: an app registration with only the signer role on this one
|
||||
certificate profile, and a client secret with an expiry. Four things are load-bearing:
|
||||
|
||||
|
||||
+3
-3
@@ -137,7 +137,7 @@ Implementation gotchas for the terminal view and its global controls (merged in
|
||||
│ │
|
||||
│ User: claude (UID/GID remapped to match host) │
|
||||
│ Entrypoint: UID/GID remap → SSH setup → git config → │
|
||||
│ docker socket perms → sleep infinity │
|
||||
│ docker socket perms → root keepalive wait │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -164,7 +164,7 @@ Terminal resize follows the same pattern: `ResizeObserver` detects container siz
|
||||
|
||||
Containers follow a **stop/start** model, not create/destroy:
|
||||
|
||||
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then runs `sleep infinity` to keep the container alive.
|
||||
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then stays resident as root waiting on a root-owned `triple-c-keepalive` sleep — never a process the `claude` user can kill, since any `pkill sleep` a Claude session runs would otherwise stop the container.
|
||||
2. **Terminal open**: `docker exec` launches `claude` with a PTY in the running container, with the permission-mode flags from `PermissionMode::cli_args()` (or `bash -l` for a shell session).
|
||||
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
|
||||
4. **Restart**: `docker start` resumes the existing container — unless `container_needs_recreation()` finds a `triple-c.*` label that no longer matches the project's settings, in which case the container is committed to a snapshot image (`triple-c-snapshot-{projectId}:latest`), removed, and recreated from that snapshot. Installed tools survive; the named volumes are untouched.
|
||||
@@ -348,7 +348,7 @@ The entrypoint solves this by:
|
||||
3. Relocating any existing system user/group that conflicts with the target UID/GID.
|
||||
4. Fixing ownership of `/home/claude` after the change.
|
||||
|
||||
This runs as root in the entrypoint, then the final `exec su -s /bin/bash claude -c "exec sleep infinity"` drops to the remapped user.
|
||||
This runs as root in the entrypoint. The entrypoint itself then stays running as root to keep the container alive; sessions reach the remapped `claude` user through `docker exec --user`. (Older images ended in `exec su -s /bin/bash claude -c "exec sleep infinity"`, which let any `claude` process stop the container by killing that sleep.)
|
||||
|
||||
### SSH Key Handling
|
||||
|
||||
|
||||
Generated
+855
-27
File diff suppressed because it is too large.
Load diff
@@ -21,17 +21,28 @@ bollard = "0.18"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
bytes = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "6"
|
||||
log = "0.4"
|
||||
fern = { version = "0.7", features = ["date-based"] }
|
||||
tar = "0.4"
|
||||
# Unpacks the pinned OpenTofu release archive (aws/tofu.rs); every platform's
|
||||
# archive is a .tar.gz. The version already in the tree.
|
||||
flate2 = "1"
|
||||
include_dir = "0.7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
|
||||
iana-time-zone = "0.1"
|
||||
sha2 = "0.10"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
# Client side of triple-c-agent's two WebSocket endpoints (host/agent.rs). The
|
||||
# same version axum's `ws` already pulls in; plain ws:// only (the transport is
|
||||
# loopback or an SSM port-forward), so no TLS features.
|
||||
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect"] }
|
||||
# `PollSender`: the agent attach's stdin `AsyncWrite` over a bounded channel.
|
||||
# Already in the tree through tokio's ecosystem.
|
||||
tokio-util = "0.7"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
@@ -47,12 +58,49 @@ similar = "2"
|
||||
# Marketplace repos are fetched on the host into a bare cache (spec §3).
|
||||
# Blocking client + rustls: no git binary or OpenSSL needed on the host.
|
||||
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
|
||||
# AWS host (docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md). The
|
||||
# service crates' default `rustls` feature is the *legacy* hyper 0.14 /
|
||||
# rustls 0.21 client, a second TLS stack next to the one reqwest already
|
||||
# brings; `default-https-client` is the current one, so defaults are off.
|
||||
aws-config = { version = "1.13.2", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso", "behavior-version-latest"] }
|
||||
aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-sts = { version = "1.126.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-iam = { version = "1.136.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
# Named by src/aws/connection.rs: the credentials-provider trait (its profile
|
||||
# wrapper) and the profile-file set (aws-config's alias for it is deprecated).
|
||||
# Both are the versions aws-config already resolves.
|
||||
aws-credential-types = { version = "1.3.1", default-features = false }
|
||||
aws-runtime = { version = "1.10.4", default-features = false }
|
||||
# The default sleep and time source for the hand-built SdkConfig (no env/profile
|
||||
# loading); the version and feature already in the tree.
|
||||
aws-smithy-async = { version = "1.3.1", default-features = false, features = ["rt-tokio"] }
|
||||
aws-sdk-s3 = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
|
||||
# aws/tofu.rs: `setsid` puts the detached OpenTofu runner in its own session,
|
||||
# and Cancel sends SIGINT to the runner process alone (never a group), which
|
||||
# forwards it to `tofu`. Liveness is the runner's `apply.lock`, not a PID.
|
||||
# Already in the tree through tokio and friends.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media
|
||||
# streams and answers the permission request (see src/linux_media.rs). Same
|
||||
# version tauri/wry already resolve, so this adds no second copy.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
# their backoff schedule under a paused clock instead of in real seconds.
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
tempfile = "3"
|
||||
# Only `aws::live_tests` (ignored): it creates a throwaway environment key to
|
||||
# check that Secrets Manager and the permissions boundary see its tags.
|
||||
aws-sdk-kms = { version = "1.131.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
# Only `aws::tofu_modules` tests: they parse the shipped OpenTofu modules.
|
||||
hcl-rs = "0.20"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -57,6 +57,26 @@
|
||||
"allow-detect-aws-config",
|
||||
"allow-inspect-ca-cert-path",
|
||||
"allow-list-aws-profiles",
|
||||
"allow-add-aws-profile-connection",
|
||||
"allow-add-aws-keys-connection",
|
||||
"allow-update-aws-connection",
|
||||
"allow-remove-host-connection",
|
||||
"allow-aws-connection-identity",
|
||||
"allow-aws-connection-sso-login",
|
||||
"allow-check-aws-permissions",
|
||||
"allow-aws-policy-document",
|
||||
"allow-detect-aws-environments",
|
||||
"allow-deploy-aws-environment",
|
||||
"allow-update-aws-environment",
|
||||
"allow-repair-aws-environment",
|
||||
"allow-destroy-incomplete-aws-environment",
|
||||
"allow-unlock-aws-state",
|
||||
"allow-follow-aws-deployment",
|
||||
"allow-cancel-aws-deployment",
|
||||
"allow-push-aws-saved-state",
|
||||
"allow-onboard-aws-user",
|
||||
"allow-deploy-aws-permissions-boundary",
|
||||
"allow-save-aws-admin-bundle",
|
||||
"allow-detect-host-timezone",
|
||||
"allow-export-settings",
|
||||
"allow-preview-settings-import",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
@@ -350,6 +350,18 @@
|
||||
"const": "allow-acquire-claude-token",
|
||||
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-keys-connection",
|
||||
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-profile-connection",
|
||||
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -392,6 +404,24 @@
|
||||
"const": "allow-apply-settings-import",
|
||||
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-identity",
|
||||
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-sso-login",
|
||||
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-policy-document",
|
||||
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -416,6 +446,12 @@
|
||||
"const": "allow-build-stt-image",
|
||||
"markdownDescription": "Enables the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-cancel-aws-deployment",
|
||||
"markdownDescription": "Enables the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -428,6 +464,12 @@
|
||||
"const": "allow-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-aws-permissions",
|
||||
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -518,12 +560,36 @@
|
||||
"const": "allow-delete-note",
|
||||
"markdownDescription": "Enables the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-environment",
|
||||
"markdownDescription": "Enables the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-config",
|
||||
"markdownDescription": "Enables the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-environments",
|
||||
"markdownDescription": "Enables the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -554,6 +620,12 @@
|
||||
"const": "allow-export-settings",
|
||||
"markdownDescription": "Enables the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-follow-aws-deployment",
|
||||
"markdownDescription": "Enables the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -770,6 +842,12 @@
|
||||
"const": "allow-migrate-project-to-base",
|
||||
"markdownDescription": "Enables the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-onboard-aws-user",
|
||||
"markdownDescription": "Enables the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -830,6 +908,12 @@
|
||||
"const": "allow-pull-stt-image",
|
||||
"markdownDescription": "Enables the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-push-aws-saved-state",
|
||||
"markdownDescription": "Enables the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +950,12 @@
|
||||
"const": "allow-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-remove-host-connection",
|
||||
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -896,6 +986,12 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-repair-aws-environment",
|
||||
"markdownDescription": "Enables the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -914,6 +1010,12 @@
|
||||
"const": "allow-run-scheduled-task-now",
|
||||
"markdownDescription": "Enables the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-save-aws-admin-bundle",
|
||||
"markdownDescription": "Enables the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1082,6 +1184,24 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-unlock-aws-state",
|
||||
"markdownDescription": "Enables the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-connection",
|
||||
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-environment",
|
||||
"markdownDescription": "Enables the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1160,6 +1280,18 @@
|
||||
"const": "deny-acquire-claude-token",
|
||||
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-keys-connection",
|
||||
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-profile-connection",
|
||||
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1202,6 +1334,24 @@
|
||||
"const": "deny-apply-settings-import",
|
||||
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-identity",
|
||||
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-sso-login",
|
||||
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-policy-document",
|
||||
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1226,6 +1376,12 @@
|
||||
"const": "deny-build-stt-image",
|
||||
"markdownDescription": "Denies the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-cancel-aws-deployment",
|
||||
"markdownDescription": "Denies the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1238,6 +1394,12 @@
|
||||
"const": "deny-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-aws-permissions",
|
||||
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1328,12 +1490,36 @@
|
||||
"const": "deny-delete-note",
|
||||
"markdownDescription": "Denies the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-environment",
|
||||
"markdownDescription": "Denies the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-config",
|
||||
"markdownDescription": "Denies the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-environments",
|
||||
"markdownDescription": "Denies the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1364,6 +1550,12 @@
|
||||
"const": "deny-export-settings",
|
||||
"markdownDescription": "Denies the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-follow-aws-deployment",
|
||||
"markdownDescription": "Denies the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1580,6 +1772,12 @@
|
||||
"const": "deny-migrate-project-to-base",
|
||||
"markdownDescription": "Denies the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-onboard-aws-user",
|
||||
"markdownDescription": "Denies the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1640,6 +1838,12 @@
|
||||
"const": "deny-pull-stt-image",
|
||||
"markdownDescription": "Denies the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-push-aws-saved-state",
|
||||
"markdownDescription": "Denies the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1676,6 +1880,12 @@
|
||||
"const": "deny-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-remove-host-connection",
|
||||
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1706,6 +1916,12 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-repair-aws-environment",
|
||||
"markdownDescription": "Denies the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1724,6 +1940,12 @@
|
||||
"const": "deny-run-scheduled-task-now",
|
||||
"markdownDescription": "Denies the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-save-aws-admin-bundle",
|
||||
"markdownDescription": "Denies the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1892,6 +2114,24 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-unlock-aws-state",
|
||||
"markdownDescription": "Denies the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-connection",
|
||||
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-environment",
|
||||
"markdownDescription": "Denies the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -350,6 +350,18 @@
|
||||
"const": "allow-acquire-claude-token",
|
||||
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-keys-connection",
|
||||
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-add-aws-profile-connection",
|
||||
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -392,6 +404,24 @@
|
||||
"const": "allow-apply-settings-import",
|
||||
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-identity",
|
||||
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-connection-sso-login",
|
||||
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-policy-document",
|
||||
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -416,6 +446,12 @@
|
||||
"const": "allow-build-stt-image",
|
||||
"markdownDescription": "Enables the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-cancel-aws-deployment",
|
||||
"markdownDescription": "Enables the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -428,6 +464,12 @@
|
||||
"const": "allow-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-aws-permissions",
|
||||
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -518,12 +560,36 @@
|
||||
"const": "allow-delete-note",
|
||||
"markdownDescription": "Enables the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-environment",
|
||||
"markdownDescription": "Enables the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-config",
|
||||
"markdownDescription": "Enables the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-environments",
|
||||
"markdownDescription": "Enables the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -554,6 +620,12 @@
|
||||
"const": "allow-export-settings",
|
||||
"markdownDescription": "Enables the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-follow-aws-deployment",
|
||||
"markdownDescription": "Enables the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -770,6 +842,12 @@
|
||||
"const": "allow-migrate-project-to-base",
|
||||
"markdownDescription": "Enables the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-onboard-aws-user",
|
||||
"markdownDescription": "Enables the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -830,6 +908,12 @@
|
||||
"const": "allow-pull-stt-image",
|
||||
"markdownDescription": "Enables the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-push-aws-saved-state",
|
||||
"markdownDescription": "Enables the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +950,12 @@
|
||||
"const": "allow-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-remove-host-connection",
|
||||
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -896,6 +986,12 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-repair-aws-environment",
|
||||
"markdownDescription": "Enables the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -914,6 +1010,12 @@
|
||||
"const": "allow-run-scheduled-task-now",
|
||||
"markdownDescription": "Enables the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-save-aws-admin-bundle",
|
||||
"markdownDescription": "Enables the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1082,6 +1184,24 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-unlock-aws-state",
|
||||
"markdownDescription": "Enables the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-connection",
|
||||
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-environment",
|
||||
"markdownDescription": "Enables the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1160,6 +1280,18 @@
|
||||
"const": "deny-acquire-claude-token",
|
||||
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-keys-connection",
|
||||
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-add-aws-profile-connection",
|
||||
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the add_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1202,6 +1334,24 @@
|
||||
"const": "deny-apply-settings-import",
|
||||
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-identity",
|
||||
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-connection-sso-login",
|
||||
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_policy_document command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-policy-document",
|
||||
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1226,6 +1376,12 @@
|
||||
"const": "deny-build-stt-image",
|
||||
"markdownDescription": "Denies the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-cancel-aws-deployment",
|
||||
"markdownDescription": "Denies the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1238,6 +1394,12 @@
|
||||
"const": "deny-cancel-marketplace-gh-login",
|
||||
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-aws-permissions",
|
||||
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1328,12 +1490,36 @@
|
||||
"const": "deny-delete-note",
|
||||
"markdownDescription": "Denies the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-environment",
|
||||
"markdownDescription": "Denies the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-config",
|
||||
"markdownDescription": "Denies the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-environments",
|
||||
"markdownDescription": "Denies the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1364,6 +1550,12 @@
|
||||
"const": "deny-export-settings",
|
||||
"markdownDescription": "Denies the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-follow-aws-deployment",
|
||||
"markdownDescription": "Denies the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1580,6 +1772,12 @@
|
||||
"const": "deny-migrate-project-to-base",
|
||||
"markdownDescription": "Denies the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-onboard-aws-user",
|
||||
"markdownDescription": "Denies the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1640,6 +1838,12 @@
|
||||
"const": "deny-pull-stt-image",
|
||||
"markdownDescription": "Denies the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-push-aws-saved-state",
|
||||
"markdownDescription": "Denies the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1676,6 +1880,12 @@
|
||||
"const": "deny-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_host_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-remove-host-connection",
|
||||
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1706,6 +1916,12 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-repair-aws-environment",
|
||||
"markdownDescription": "Denies the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1724,6 +1940,12 @@
|
||||
"const": "deny-run-scheduled-task-now",
|
||||
"markdownDescription": "Denies the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-save-aws-admin-bundle",
|
||||
"markdownDescription": "Denies the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1892,6 +2114,24 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-unlock-aws-state",
|
||||
"markdownDescription": "Denies the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-connection",
|
||||
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-environment",
|
||||
"markdownDescription": "Denies the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
//! |---|---|
|
||||
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
|
||||
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
|
||||
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
|
||||
//! | Container stopped/died another way | poller's own `is_running` check → loop exits |
|
||||
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
|
||||
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
|
||||
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
|
||||
@@ -55,8 +55,7 @@ use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
|
||||
use crate::host::{ContainerTarget, PROC_NET_OUTPUT_LIMIT};
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use proc_net::PortFamily;
|
||||
@@ -178,7 +177,7 @@ impl AuthBridgeManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) {
|
||||
@@ -200,12 +199,12 @@ impl AuthBridgeManager {
|
||||
log::info!(
|
||||
"Auth bridge: starting for project {} (container {})",
|
||||
project_id,
|
||||
&container_id[..container_id.len().min(12)]
|
||||
&container.id()[..container.id().len().min(12)]
|
||||
);
|
||||
|
||||
let poller = tokio::spawn(poll_loop(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
epoch,
|
||||
app,
|
||||
store,
|
||||
@@ -276,7 +275,7 @@ impl AuthBridgeManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn poll_loop(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
epoch: u64,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
@@ -301,7 +300,7 @@ async fn poll_loop(
|
||||
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
@@ -327,7 +326,9 @@ async fn poll_loop(
|
||||
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||
let discovery = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
|
||||
res = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
|
||||
res.map(|(out, _)| out)
|
||||
}
|
||||
};
|
||||
|
||||
match discovery {
|
||||
@@ -338,7 +339,7 @@ async fn poll_loop(
|
||||
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||
// bridge is running, and a stale reservation set is a hole.
|
||||
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
if reconcile(&container, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
}
|
||||
@@ -528,7 +529,7 @@ const MAX_CONFLICTS: usize = 32;
|
||||
/// Bring the set of host listeners in line with what the container is currently
|
||||
/// listening on. Returns whether anything the UI cares about changed.
|
||||
async fn reconcile(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
discovered: &BTreeMap<u16, PortFamily>,
|
||||
skip: &HashSet<u16>,
|
||||
state: &Arc<Mutex<BridgeState>>,
|
||||
@@ -581,7 +582,7 @@ async fn reconcile(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||
match PortForward::bind(container.clone(), port, family).await {
|
||||
Ok(forward) => {
|
||||
if st.conflicts.remove(&port).is_some() {
|
||||
log::info!("Auth bridge: host port {} became available", port);
|
||||
@@ -831,7 +832,13 @@ mod tests {
|
||||
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
|
||||
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||
|
||||
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
|
||||
reconcile(
|
||||
&ContainerTarget::docker("no-such-container"),
|
||||
&discovered,
|
||||
&HashSet::new(),
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut st = state.lock().await;
|
||||
assert!(
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! and stdout wired to the TCP socket. `socat` ships in the container image.
|
||||
//!
|
||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||
//! [`crate::host::ContainerTarget::attach_exec`], the same helper the
|
||||
//! interactive terminal sessions are built on.
|
||||
//!
|
||||
//! ## What the host listener is, and is not
|
||||
@@ -56,14 +56,12 @@
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecChunk};
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
use crate::docker::exec::{create_attached_exec, AttachedExec};
|
||||
|
||||
use super::proc_net::PortFamily;
|
||||
|
||||
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
|
||||
@@ -143,13 +141,13 @@ impl Drop for PortForward {
|
||||
}
|
||||
|
||||
impl PortForward {
|
||||
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
||||
/// Bind `port` on the host loopback and start proxying into `container`.
|
||||
///
|
||||
/// The bind happens before the task is spawned, so an already-taken port is
|
||||
/// reported to the caller as an error rather than disappearing into a
|
||||
/// background task.
|
||||
pub async fn bind(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
port: u16,
|
||||
family: PortFamily,
|
||||
) -> Result<Self, std::io::Error> {
|
||||
@@ -196,7 +194,7 @@ impl PortForward {
|
||||
};
|
||||
|
||||
let target = family.socat_target(port);
|
||||
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||
let task = tokio::spawn(accept_loop(container, port, target, v4, v6));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
@@ -219,7 +217,7 @@ impl PortForward {
|
||||
/// Accept on both loopback listeners until aborted. Dropping this future drops
|
||||
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
|
||||
async fn accept_loop(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
port: u16,
|
||||
target: String,
|
||||
v4: TcpListener,
|
||||
@@ -258,7 +256,7 @@ async fn accept_loop(
|
||||
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(tunnel_connection(
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
target.clone(),
|
||||
stream,
|
||||
port,
|
||||
@@ -288,7 +286,12 @@ async fn accept_optional(
|
||||
///
|
||||
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||
/// reaches the container at all — not even a `docker exec`.
|
||||
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||
async fn tunnel_connection(
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
mut stream: TcpStream,
|
||||
port: u16,
|
||||
) {
|
||||
let head = match read_leading_bytes(&mut stream).await {
|
||||
Ok(head) => head,
|
||||
Err(e) => {
|
||||
@@ -319,7 +322,7 @@ async fn tunnel_connection(container_id: String, target: String, mut stream: Tcp
|
||||
}
|
||||
|
||||
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
|
||||
tunnel_connection_with_prelude(container, target, stream, port, head.into_buffer()).await
|
||||
}
|
||||
|
||||
/// What the first bytes of an accepted connection turned out to be.
|
||||
@@ -512,7 +515,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
||||
/// re-serialising a parsed request.
|
||||
pub async fn tunnel_connection_with_prelude(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
@@ -524,7 +527,7 @@ pub async fn tunnel_connection_with_prelude(
|
||||
mut output,
|
||||
mut input,
|
||||
..
|
||||
} = match create_attached_exec(&container_id, cmd, false).await {
|
||||
} = match container.attach_exec(cmd, false).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
@@ -587,12 +590,12 @@ pub async fn tunnel_connection_with_prelude(
|
||||
// Only stdout is payload. The exec is created with tty = false
|
||||
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||
// diagnostics out of the proxied byte stream.
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if host_tx.write_all(&message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
log::debug!(
|
||||
"Auth bridge: socat stderr for port {}: {}",
|
||||
port,
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! An `SdkConfig` built from a saved `HostConnection`, never from the
|
||||
//! process's default credentials chain.
|
||||
|
||||
use aws_config::identity::IdentityCache;
|
||||
use aws_config::profile::ProfileFileCredentialsProvider;
|
||||
use aws_config::provider_config::ProviderConfig;
|
||||
use aws_config::retry::RetryConfig;
|
||||
use aws_config::{BehaviorVersion, Region, SdkConfig};
|
||||
use aws_credential_types::provider::error::CredentialsError;
|
||||
use aws_credential_types::provider::{future, ProvideCredentials, SharedCredentialsProvider};
|
||||
use aws_credential_types::Credentials;
|
||||
use aws_runtime::env_config::file::EnvConfigFiles;
|
||||
use aws_smithy_async::rt::sleep::default_async_sleep;
|
||||
use aws_smithy_async::time::SharedTimeSource;
|
||||
|
||||
use super::error::{AwsError, ProfileCredentialsError};
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use crate::storage::secure;
|
||||
|
||||
/// The SDK config for `conn` in `region`. Credentials are resolved lazily,
|
||||
/// so a missing profile or an expired SSO session surfaces on the first
|
||||
/// call and is classified there (`classify_sdk_error`).
|
||||
pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result<SdkConfig, AwsError> {
|
||||
sdk_config_with(conn, region, None).await
|
||||
}
|
||||
|
||||
/// `sdk_config` with the profile files overridden (tests pass temp files so
|
||||
/// neither the process environment nor `~/.aws` is consulted).
|
||||
pub(crate) async fn sdk_config_with(
|
||||
conn: &HostConnection,
|
||||
region: &str,
|
||||
profile_files: Option<EnvConfigFiles>,
|
||||
) -> Result<SdkConfig, AwsError> {
|
||||
let HostKind::Aws { auth, .. } = &conn.kind;
|
||||
let region = Region::new(region.to_string());
|
||||
let credentials = match auth {
|
||||
AwsAuth::Profile { name } => {
|
||||
// The profile is read for credentials only (static keys, SSO,
|
||||
// assume-role, credential_process), never for endpoints or
|
||||
// service settings.
|
||||
let mut builder = ProfileFileCredentialsProvider::builder()
|
||||
.configure(&ProviderConfig::default().with_region(Some(region.clone())))
|
||||
.profile_name(name);
|
||||
if let Some(files) = profile_files {
|
||||
builder = builder.profile_files(files);
|
||||
}
|
||||
SharedCredentialsProvider::new(NamedProfileProvider {
|
||||
profile: name.clone(),
|
||||
inner: builder.build(),
|
||||
})
|
||||
}
|
||||
AwsAuth::Keys { keyring_ref } => {
|
||||
let keys = secure::get_aws_keys(keyring_ref)
|
||||
.map_err(|message| AwsError::Other { message })?
|
||||
.ok_or(AwsError::KeysMissing)?;
|
||||
keys_credentials(&keys)
|
||||
}
|
||||
};
|
||||
Ok(build_sdk_config(region, credentials))
|
||||
}
|
||||
|
||||
/// The SDK config for keys not yet saved (the add-connection check runs
|
||||
/// before anything touches the keychain).
|
||||
pub fn sdk_config_for_keys(keys: &secure::AwsKeys, region: &str) -> SdkConfig {
|
||||
build_sdk_config(Region::new(region.to_string()), keys_credentials(keys))
|
||||
}
|
||||
|
||||
fn keys_credentials(keys: &secure::AwsKeys) -> SharedCredentialsProvider {
|
||||
SharedCredentialsProvider::new(Credentials::new(
|
||||
keys.access_key_id.as_str(),
|
||||
keys.secret_access_key.as_str(),
|
||||
keys.session_token.clone(),
|
||||
None,
|
||||
"triple-c-connection",
|
||||
))
|
||||
}
|
||||
|
||||
/// Built by hand rather than with `aws_config::defaults(..).load()`, which
|
||||
/// would read `AWS_ENDPOINT_URL[_<SERVICE>]`, FIPS/dual-stack, retry and
|
||||
/// other settings from the process environment and the default profile. A
|
||||
/// stray endpoint there would send every connection's signed requests (and
|
||||
/// the permission check) somewhere other than AWS. Connections talk to the
|
||||
/// real regional endpoints; a profile's own `endpoint_url` is ignored too.
|
||||
/// The HTTP client, timeouts and stalled-stream protection are each service
|
||||
/// client's defaults (the `default-https-client` stack every AWS crate here
|
||||
/// is built with).
|
||||
fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> SdkConfig {
|
||||
let mut builder = SdkConfig::builder()
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.region(region)
|
||||
.credentials_provider(credentials)
|
||||
.identity_cache(IdentityCache::lazy().build())
|
||||
.retry_config(RetryConfig::standard())
|
||||
.time_source(SharedTimeSource::default());
|
||||
if let Some(sleep) = default_async_sleep() {
|
||||
builder = builder.sleep_impl(sleep);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Puts the profile's name into the source chain of every credentials error,
|
||||
/// so a failure deep inside the SDK can be reported against the profile.
|
||||
#[derive(Debug)]
|
||||
struct NamedProfileProvider {
|
||||
profile: String,
|
||||
inner: ProfileFileCredentialsProvider,
|
||||
}
|
||||
|
||||
impl NamedProfileProvider {
|
||||
async fn load(&self) -> Result<Credentials, CredentialsError> {
|
||||
self.inner.provide_credentials().await.map_err(|e| {
|
||||
let not_loaded = matches!(e, CredentialsError::CredentialsNotLoaded(_));
|
||||
let timed_out = matches!(e, CredentialsError::ProviderTimedOut(_));
|
||||
if timed_out {
|
||||
return e;
|
||||
}
|
||||
let invalid = matches!(e, CredentialsError::InvalidConfiguration(_));
|
||||
let wrapped = ProfileCredentialsError {
|
||||
profile: self.profile.clone(),
|
||||
not_loaded,
|
||||
source: e,
|
||||
};
|
||||
// Keep the error's kind; only its context changes.
|
||||
if not_loaded {
|
||||
CredentialsError::not_loaded(wrapped)
|
||||
} else if invalid {
|
||||
CredentialsError::invalid_configuration(wrapped)
|
||||
} else {
|
||||
CredentialsError::provider_error(wrapped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProvideCredentials for NamedProfileProvider {
|
||||
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
|
||||
where
|
||||
Self: 'a,
|
||||
{
|
||||
future::ProvideCredentials::new(self.load())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aws::identity::caller_identity;
|
||||
use crate::aws::AwsError;
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use aws_runtime::env_config::file::{EnvConfigFileKind, EnvConfigFiles};
|
||||
|
||||
fn profile_conn(name: &str) -> HostConnection {
|
||||
HostConnection {
|
||||
id: "c1".into(),
|
||||
name: "Test".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: name.into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty config and credentials files, passed explicitly so neither the
|
||||
/// process environment nor ~/.aws is consulted.
|
||||
#[tokio::test]
|
||||
async fn a_missing_profile_is_reported_by_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
let creds = dir.path().join("credentials");
|
||||
std::fs::write(&config, "").unwrap();
|
||||
std::fs::write(&creds, "").unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.with_file(EnvConfigFileKind::Credentials, &creds)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
let err = caller_identity(&cfg).await.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
AwsError::ProfileNotFound {
|
||||
profile: "nope".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A profile that the files do define but under another name is still
|
||||
/// "not found" for the name the connection asked for.
|
||||
#[tokio::test]
|
||||
async fn a_profile_absent_from_populated_files_is_reported_by_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"[profile other]\naws_access_key_id = AKIDTEST\naws_secret_access_key = s\n",
|
||||
)
|
||||
.unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
let err = caller_identity(&cfg).await.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
AwsError::ProfileNotFound {
|
||||
profile: "nope".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_region_comes_from_the_argument() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
std::fs::write(&config, "[profile p]\nregion = eu-west-1\n").unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("p"), "ap-south-1", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("ap-south-1"));
|
||||
}
|
||||
|
||||
/// Config files that try to redirect every call. A connection always
|
||||
/// talks to the real regional AWS endpoints, so none of this may reach
|
||||
/// the config: not the named profile's `endpoint_url`, not `[default]`'s,
|
||||
/// not a per-service `services` section.
|
||||
const REDIRECTING_CONFIG: &str = "[default]
|
||||
endpoint_url = http://127.0.0.1:9
|
||||
retry_mode = adaptive
|
||||
max_attempts = 9
|
||||
use_fips_endpoint = true
|
||||
|
||||
[profile p]
|
||||
endpoint_url = http://127.0.0.1:9
|
||||
retry_mode = adaptive
|
||||
max_attempts = 9
|
||||
use_dualstack_endpoint = true
|
||||
services = redirect
|
||||
aws_access_key_id = AKIDTEST
|
||||
aws_secret_access_key = s
|
||||
|
||||
[services redirect]
|
||||
sts =
|
||||
endpoint_url = http://127.0.0.1:9
|
||||
";
|
||||
|
||||
fn assert_isolated(cfg: &aws_config::SdkConfig) {
|
||||
assert_eq!(cfg.endpoint_url(), None);
|
||||
assert!(
|
||||
cfg.service_config().is_none(),
|
||||
"service config loaded from env/profile"
|
||||
);
|
||||
assert_eq!(cfg.use_fips(), None);
|
||||
assert_eq!(cfg.use_dual_stack(), None);
|
||||
let retry = cfg.retry_config().expect("retry config");
|
||||
assert_eq!(retry.mode(), aws_config::retry::RetryMode::Standard);
|
||||
assert_eq!(retry.max_attempts(), 3);
|
||||
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("us-west-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_profile_connection_ignores_endpoint_and_service_settings_in_the_profile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = dir.path().join("config");
|
||||
std::fs::write(&config, REDIRECTING_CONFIG).unwrap();
|
||||
let files = EnvConfigFiles::builder()
|
||||
.include_default_config_file(false)
|
||||
.include_default_credentials_file(false)
|
||||
.with_file(EnvConfigFileKind::Config, &config)
|
||||
.build();
|
||||
let cfg = sdk_config_with(&profile_conn("p"), "us-west-2", Some(files))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_isolated(&cfg);
|
||||
}
|
||||
|
||||
/// The keys arm reads no files or environment at all: same isolation.
|
||||
#[test]
|
||||
fn a_keys_connection_config_is_isolated() {
|
||||
let keys = crate::storage::secure::AwsKeys {
|
||||
access_key_id: "AKIDTEST".into(),
|
||||
secret_access_key: "s".into(),
|
||||
session_token: None,
|
||||
};
|
||||
let cfg = build_sdk_config(Region::new("us-west-2"), keys_credentials(&keys));
|
||||
assert_isolated(&cfg);
|
||||
assert!(cfg.credentials_provider().is_some());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,157 @@
|
||||
//! What can go wrong talking to AWS, in the terms the UI acts on.
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
use aws_sdk_sts::error::{DisplayErrorContext, ErrorMetadata};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AwsError {
|
||||
/// The UI offers "Sign in".
|
||||
SsoExpired {
|
||||
profile: String,
|
||||
},
|
||||
ProfileNotFound {
|
||||
profile: String,
|
||||
},
|
||||
/// The keychain entry for a keys connection is gone.
|
||||
KeysMissing,
|
||||
InvalidCredentials {
|
||||
message: String,
|
||||
},
|
||||
AccessDenied {
|
||||
action: String,
|
||||
message: String,
|
||||
},
|
||||
/// `s3:CreateBucket` said `BucketAlreadyExists`: the state bucket's
|
||||
/// name belongs to another AWS account. Never treated as success.
|
||||
StateBucketTaken {
|
||||
bucket: String,
|
||||
},
|
||||
Other {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AwsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AwsError::SsoExpired { profile } if profile.is_empty() => {
|
||||
write!(f, "The AWS SSO session has expired; sign in again")
|
||||
}
|
||||
AwsError::SsoExpired { profile } => write!(
|
||||
f,
|
||||
"The AWS SSO session for profile \"{profile}\" has expired; sign in again"
|
||||
),
|
||||
AwsError::ProfileNotFound { profile } => write!(
|
||||
f,
|
||||
"AWS profile \"{profile}\" was not found, or has no credentials, in the AWS config and credentials files"
|
||||
),
|
||||
AwsError::KeysMissing => write!(
|
||||
f,
|
||||
"The access keys for this connection are no longer in the system keychain; enter them again"
|
||||
),
|
||||
AwsError::InvalidCredentials { message } => {
|
||||
write!(f, "AWS rejected the credentials: {message}")
|
||||
}
|
||||
AwsError::AccessDenied { action, message } => {
|
||||
write!(f, "Access denied for {action}: {message}")
|
||||
}
|
||||
AwsError::StateBucketTaken { bucket } => write!(
|
||||
f,
|
||||
"The state bucket name \"{bucket}\" is already taken by another AWS account, so \
|
||||
Triple-C can't keep OpenTofu state there"
|
||||
),
|
||||
AwsError::Other { message } => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carried in the source chain of every credentials error from a profile
|
||||
/// connection, so classification knows which profile failed and whether it
|
||||
/// produced no credentials at all (absent, or present without credentials).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProfileCredentialsError {
|
||||
pub profile: String,
|
||||
pub not_loaded: bool,
|
||||
pub source: aws_credential_types::provider::error::CredentialsError,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProfileCredentialsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "credentials for profile `{}`", self.profile)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ProfileCredentialsError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&self.source)
|
||||
}
|
||||
}
|
||||
|
||||
const SSO_EXPIRED: &[&str] = &[
|
||||
"The SSO session associated with this profile has expired",
|
||||
"Token has expired",
|
||||
"the SSO token has expired",
|
||||
];
|
||||
const SSO_REVOKED: &[&str] = &[
|
||||
"UnauthorizedException",
|
||||
"Session token not found or invalid",
|
||||
];
|
||||
const DENIED_CODES: &[&str] = &[
|
||||
"AccessDeniedException",
|
||||
"AccessDenied",
|
||||
"UnauthorizedOperation",
|
||||
];
|
||||
const BAD_CREDENTIAL_CODES: &[&str] = &[
|
||||
"ExpiredToken",
|
||||
"InvalidClientTokenId",
|
||||
"SignatureDoesNotMatch",
|
||||
"UnrecognizedClientException",
|
||||
];
|
||||
|
||||
/// Classify any SDK error (an `SdkError`, a credentials error, ...) from
|
||||
/// calling `action` (e.g. `iam:GetRole`). Matching is on the full display
|
||||
/// chain, which carries both provider messages and service error codes.
|
||||
pub fn classify_sdk_error(action: &str, err: &(dyn Error + 'static)) -> AwsError {
|
||||
let text = DisplayErrorContext(err).to_string();
|
||||
let mut chain = std::iter::successors(Some(err), |e| (*e).source());
|
||||
let profile = chain
|
||||
.clone()
|
||||
.find_map(|e| e.downcast_ref::<ProfileCredentialsError>());
|
||||
// The service's own message, when one is in the chain.
|
||||
let service_message = chain
|
||||
.find_map(|e| e.downcast_ref::<ErrorMetadata>())
|
||||
.and_then(|m| m.message())
|
||||
.map(str::to_string);
|
||||
let message = service_message.unwrap_or_else(|| text.clone());
|
||||
|
||||
if SSO_EXPIRED.iter().any(|s| text.contains(s)) {
|
||||
return AwsError::SsoExpired {
|
||||
profile: profile.map(|p| p.profile.clone()).unwrap_or_default(),
|
||||
};
|
||||
}
|
||||
// SSO's GetRoleCredentials on a revoked or signed-out session. Only
|
||||
// inside a profile's credentials: other services use this code too.
|
||||
if let Some(p) = profile.filter(|_| SSO_REVOKED.iter().any(|s| text.contains(s))) {
|
||||
return AwsError::SsoExpired {
|
||||
profile: p.profile.clone(),
|
||||
};
|
||||
}
|
||||
if let Some(p) = profile.filter(|p| p.not_loaded) {
|
||||
return AwsError::ProfileNotFound {
|
||||
profile: p.profile.clone(),
|
||||
};
|
||||
}
|
||||
if DENIED_CODES.iter().any(|c| text.contains(c)) {
|
||||
return AwsError::AccessDenied {
|
||||
action: action.to_string(),
|
||||
message,
|
||||
};
|
||||
}
|
||||
if BAD_CREDENTIAL_CODES.iter().any(|c| text.contains(c)) {
|
||||
return AwsError::InvalidCredentials { message };
|
||||
}
|
||||
AwsError::Other { message: text }
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Who the connection's credentials are, and which IAM principal the
|
||||
//! policy simulator should be asked about.
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct CallerIdentity {
|
||||
pub account: String,
|
||||
pub arn: String,
|
||||
/// STS `UserId`: the principal's unique id (for a role session,
|
||||
/// `<role id>:<session name>`).
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// `sts:GetCallerIdentity`, which needs no IAM permission: it is the
|
||||
/// connection test itself.
|
||||
pub async fn caller_identity(cfg: &SdkConfig) -> Result<CallerIdentity, AwsError> {
|
||||
const ACTION: &str = "sts:GetCallerIdentity";
|
||||
let out = aws_sdk_sts::Client::new(cfg)
|
||||
.get_caller_identity()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
let field = |v: Option<&str>, name: &str| {
|
||||
v.map(str::to_string).ok_or_else(|| AwsError::Other {
|
||||
message: format!("{ACTION} returned no {name}"),
|
||||
})
|
||||
};
|
||||
Ok(CallerIdentity {
|
||||
account: field(out.account(), "Account")?,
|
||||
arn: field(out.arn(), "Arn")?,
|
||||
user_id: field(out.user_id(), "UserId")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The principal to simulate, as far as the caller's ARN tells us.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PrincipalArn {
|
||||
/// An IAM user or role ARN, usable as `PolicySourceArn` directly.
|
||||
Exact(String),
|
||||
/// A role session: the role's ARN carries a path the session ARN lacks,
|
||||
/// so it has to be looked up (`resolve_principal`).
|
||||
AssumedRole { account: String, role_name: String },
|
||||
}
|
||||
|
||||
/// Map the caller's ARN to a simulatable principal. Root, federated users
|
||||
/// and anything unrecognised cannot be simulated; the caller falls back to
|
||||
/// probing instead.
|
||||
pub fn principal_for_simulation(caller_arn: &str) -> Result<PrincipalArn, AwsError> {
|
||||
let unsupported = || AwsError::Other {
|
||||
message: format!("IAM policy simulation isn't possible for the principal {caller_arn}"),
|
||||
};
|
||||
// arn:<partition>:<service>:<region>:<account>:<resource>
|
||||
let mut parts = caller_arn.splitn(6, ':');
|
||||
let (Some("arn"), Some(partition), Some(service), Some(_region), Some(account), Some(resource)) = (
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
parts.next(),
|
||||
) else {
|
||||
return Err(unsupported());
|
||||
};
|
||||
if partition.is_empty() || account.is_empty() {
|
||||
return Err(unsupported());
|
||||
}
|
||||
match service {
|
||||
"iam" if resource.starts_with("user/") || resource.starts_with("role/") => {
|
||||
Ok(PrincipalArn::Exact(caller_arn.to_string()))
|
||||
}
|
||||
"sts" => match resource
|
||||
.strip_prefix("assumed-role/")
|
||||
.and_then(|r| r.split_once('/'))
|
||||
{
|
||||
Some((role_name, _session)) if !role_name.is_empty() => Ok(PrincipalArn::AssumedRole {
|
||||
account: account.to_string(),
|
||||
role_name: role_name.to_string(),
|
||||
}),
|
||||
_ => Err(unsupported()),
|
||||
},
|
||||
_ => Err(unsupported()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The full ARN to simulate. An assumed role needs `iam:GetRole` to recover
|
||||
/// the role's path (SSO roles live under `/aws-reserved/...`).
|
||||
pub async fn resolve_principal(
|
||||
iam: &aws_sdk_iam::Client,
|
||||
p: PrincipalArn,
|
||||
) -> Result<String, AwsError> {
|
||||
const ACTION: &str = "iam:GetRole";
|
||||
let (account, role_name) = match p {
|
||||
PrincipalArn::Exact(arn) => return Ok(arn),
|
||||
PrincipalArn::AssumedRole { account, role_name } => (account, role_name),
|
||||
};
|
||||
let out = iam
|
||||
.get_role()
|
||||
.role_name(&role_name)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
let arn = out
|
||||
.role()
|
||||
.map(|r| r.arn().to_string())
|
||||
.filter(|a| !a.is_empty())
|
||||
.ok_or_else(|| AwsError::Other {
|
||||
message: format!("{ACTION} returned no role for {role_name}"),
|
||||
})?;
|
||||
// The role must be the session's own: same account.
|
||||
if arn.split(':').nth(4) != Some(account.as_str()) {
|
||||
return Err(AwsError::Other {
|
||||
message: format!("{ACTION} for {role_name} returned {arn}, outside account {account}"),
|
||||
});
|
||||
}
|
||||
Ok(arn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aws::error::classify_sdk_error;
|
||||
use crate::aws::AwsError;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn iam_user_arn_is_used_as_is() {
|
||||
let arn = "arn:aws:iam::111122223333:user/alice";
|
||||
assert_eq!(
|
||||
principal_for_simulation(arn).unwrap(),
|
||||
PrincipalArn::Exact(arn.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iam_role_arn_is_used_as_is() {
|
||||
let arn = "arn:aws:iam::111122223333:role/ops/deployer";
|
||||
assert_eq!(
|
||||
principal_for_simulation(arn).unwrap(),
|
||||
PrincipalArn::Exact(arn.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assumed_role_arn_maps_to_role_name() {
|
||||
let arn = "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com";
|
||||
assert_eq!(
|
||||
principal_for_simulation(arn).unwrap(),
|
||||
PrincipalArn::AssumedRole {
|
||||
account: "111122223333".into(),
|
||||
role_name: "AWSReservedSSO_Admin_abc".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_and_federated_arns_are_rejected_for_simulation() {
|
||||
for arn in [
|
||||
"arn:aws:iam::1:root",
|
||||
"arn:aws:sts::1:federated-user/bob",
|
||||
"not-an-arn",
|
||||
] {
|
||||
match principal_for_simulation(arn) {
|
||||
Err(AwsError::Other { message }) => {
|
||||
assert!(
|
||||
message.contains("simulation isn't possible"),
|
||||
"{arn}: {message}"
|
||||
)
|
||||
}
|
||||
other => panic!("{arn}: expected Other, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-shot-per-connection HTTP server that answers every request with
|
||||
/// `status` and `body`, and reports each request it saw.
|
||||
async fn fake_endpoint(
|
||||
status: &'static str,
|
||||
body: &'static str,
|
||||
) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut req = Vec::new();
|
||||
loop {
|
||||
let n = match sock.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => n,
|
||||
};
|
||||
req.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&req).into_owned();
|
||||
let Some(head_end) = text.find("\r\n\r\n") else {
|
||||
continue;
|
||||
};
|
||||
let len = text[..head_end]
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.eq_ignore_ascii_case("content-length")
|
||||
.then(|| v.trim().parse::<usize>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if req.len() < head_end + 4 + len {
|
||||
continue;
|
||||
}
|
||||
let _ = tx.send(text);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
req.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(format!("http://127.0.0.1:{port}"), rx)
|
||||
}
|
||||
|
||||
fn iam_client(endpoint: String) -> aws_sdk_iam::Client {
|
||||
let conf = aws_sdk_iam::Config::builder()
|
||||
.behavior_version(aws_sdk_iam::config::BehaviorVersion::latest())
|
||||
.region(aws_sdk_iam::config::Region::new("us-east-1"))
|
||||
.credentials_provider(aws_sdk_iam::config::Credentials::new(
|
||||
"AKIDTEST", "secret", None, None, "test",
|
||||
))
|
||||
.endpoint_url(endpoint)
|
||||
.build();
|
||||
aws_sdk_iam::Client::from_conf(conf)
|
||||
}
|
||||
|
||||
const ROLE_ARN: &str = "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc";
|
||||
|
||||
#[tokio::test]
|
||||
async fn assumed_role_arn_maps_to_role_arn_with_path() {
|
||||
const BODY: &str = r#"<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
|
||||
<GetRoleResult>
|
||||
<Role>
|
||||
<Path>/aws-reserved/sso.amazonaws.com/us-west-2/</Path>
|
||||
<RoleName>AWSReservedSSO_Admin_abc</RoleName>
|
||||
<RoleId>AROAEXAMPLEID</RoleId>
|
||||
<Arn>arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc</Arn>
|
||||
<CreateDate>2024-01-01T00:00:00Z</CreateDate>
|
||||
</Role>
|
||||
</GetRoleResult>
|
||||
<ResponseMetadata><RequestId>req-1</RequestId></ResponseMetadata>
|
||||
</GetRoleResponse>"#;
|
||||
let (endpoint, mut seen) = fake_endpoint("200 OK", BODY).await;
|
||||
let p = PrincipalArn::AssumedRole {
|
||||
account: "111122223333".into(),
|
||||
role_name: "AWSReservedSSO_Admin_abc".into(),
|
||||
};
|
||||
let arn = resolve_principal(&iam_client(endpoint), p).await.unwrap();
|
||||
assert_eq!(arn, ROLE_ARN);
|
||||
let req = seen.recv().await.unwrap();
|
||||
assert!(req.contains("Action=GetRole"), "{req}");
|
||||
assert!(req.contains("RoleName=AWSReservedSSO_Admin_abc"), "{req}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_exact_principal_needs_no_lookup() {
|
||||
// No endpoint answers: any request would fail the test.
|
||||
let client = iam_client("http://127.0.0.1:9".into());
|
||||
let arn = "arn:aws:iam::111122223333:user/alice";
|
||||
let got = resolve_principal(&client, PrincipalArn::Exact(arn.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got, arn);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_denied_get_role_is_access_denied_for_get_role() {
|
||||
const BODY: &str = r#"<ErrorResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
|
||||
<Error>
|
||||
<Type>Sender</Type>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>User: arn:aws:sts::111122223333:assumed-role/R/alice is not authorized to perform: iam:GetRole</Message>
|
||||
</Error>
|
||||
<RequestId>req-2</RequestId>
|
||||
</ErrorResponse>"#;
|
||||
let (endpoint, _seen) = fake_endpoint("403 Forbidden", BODY).await;
|
||||
let p = PrincipalArn::AssumedRole {
|
||||
account: "111122223333".into(),
|
||||
role_name: "R".into(),
|
||||
};
|
||||
match resolve_principal(&iam_client(endpoint), p).await {
|
||||
Err(AwsError::AccessDenied { action, message }) => {
|
||||
assert_eq!(action, "iam:GetRole");
|
||||
assert!(
|
||||
message.contains("is not authorized to perform: iam:GetRole"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected AccessDenied, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Msg(&'static str, Option<Box<dyn std::error::Error + 'static>>);
|
||||
impl std::fmt::Display for Msg {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for Msg {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
self.1.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_sso_token_is_reported_as_sso_expired() {
|
||||
for leaf in [
|
||||
"The SSO session associated with this profile has expired or is otherwise invalid",
|
||||
"Token has expired and refresh failed",
|
||||
] {
|
||||
// Deep in the chain, as the SDK reports it.
|
||||
let err = Msg("dispatch failure", Some(Box::new(Msg(leaf, None))));
|
||||
assert!(
|
||||
matches!(
|
||||
classify_sdk_error("sts:GetCallerIdentity", &err),
|
||||
AwsError::SsoExpired { .. }
|
||||
),
|
||||
"{leaf}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// SSO's GetRoleCredentials on a revoked or signed-out session. Inside a
|
||||
/// profile connection's credentials that means "sign in again".
|
||||
#[test]
|
||||
fn a_revoked_sso_session_is_reported_as_sso_expired() {
|
||||
use crate::aws::error::ProfileCredentialsError;
|
||||
use aws_credential_types::provider::error::CredentialsError;
|
||||
let leaf = CredentialsError::provider_error(
|
||||
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
|
||||
);
|
||||
let err = CredentialsError::provider_error(ProfileCredentialsError {
|
||||
profile: "dev".into(),
|
||||
not_loaded: false,
|
||||
source: leaf,
|
||||
});
|
||||
assert_eq!(
|
||||
classify_sdk_error(
|
||||
"sts:GetCallerIdentity",
|
||||
&Msg("dispatch failure", Some(Box::new(err)))
|
||||
),
|
||||
AwsError::SsoExpired {
|
||||
profile: "dev".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The same error code from some other service, outside any profile's
|
||||
/// credentials, is not an SSO problem.
|
||||
#[test]
|
||||
fn an_unauthorized_exception_outside_profile_credentials_is_not_sso() {
|
||||
let err = Msg(
|
||||
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
|
||||
None,
|
||||
);
|
||||
assert!(!matches!(
|
||||
classify_sdk_error("ecs:ListClusters", &err),
|
||||
AwsError::SsoExpired { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_error_codes_are_classified() {
|
||||
let denied = Msg(
|
||||
"service error: unhandled error (AccessDeniedException)",
|
||||
None,
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_sdk_error("ecs:ListClusters", &denied),
|
||||
AwsError::AccessDenied { action, .. } if action == "ecs:ListClusters"
|
||||
));
|
||||
for code in [
|
||||
"ExpiredToken",
|
||||
"InvalidClientTokenId",
|
||||
"SignatureDoesNotMatch",
|
||||
] {
|
||||
let text: &'static str =
|
||||
Box::leak(format!("service error: unhandled error ({code})").into_boxed_str());
|
||||
assert!(
|
||||
matches!(
|
||||
classify_sdk_error("sts:GetCallerIdentity", &Msg(text, None)),
|
||||
AwsError::InvalidCredentials { .. }
|
||||
),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
classify_sdk_error(
|
||||
"sts:GetCallerIdentity",
|
||||
&Msg("io error: connection refused", None)
|
||||
),
|
||||
AwsError::Other { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
//! AWS connection plumbing: an SDK config built from a saved
|
||||
//! `HostConnection`, the caller's identity, the tier policies and the
|
||||
//! permission check.
|
||||
//!
|
||||
|
||||
mod connection;
|
||||
pub mod environments;
|
||||
mod error;
|
||||
mod identity;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
pub mod permissions;
|
||||
pub mod policies;
|
||||
#[cfg(test)]
|
||||
mod policy_eval;
|
||||
pub mod tofu;
|
||||
pub mod tofu_modules;
|
||||
|
||||
pub use connection::{sdk_config, sdk_config_for_keys};
|
||||
pub use error::{classify_sdk_error, AwsError};
|
||||
pub use identity::{caller_identity, CallerIdentity};
|
||||
@@ -0,0 +1,544 @@
|
||||
//! The permission check: which Triple-C tier the connection's credentials
|
||||
//! can act as, by simulating each tier's checks (`policies::checks`) with
|
||||
//! IAM's policy simulator, or — when the caller may not simulate itself —
|
||||
//! by a few read-only probes.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use futures_util::stream::{self, StreamExt, TryStreamExt};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
use super::identity::{self, principal_for_simulation, CallerIdentity, PrincipalArn};
|
||||
use super::policies::{checks, render, Check, ContextValue, Placeholders, Tier};
|
||||
|
||||
/// How many simulations are in flight at once.
|
||||
const CONCURRENCY: usize = 2;
|
||||
|
||||
/// One `SimulatePrincipalPolicy` request, fully rendered.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SimulationRequest {
|
||||
pub principal: String,
|
||||
pub action: String,
|
||||
pub resource: String,
|
||||
pub context: Vec<ContextEntry>,
|
||||
}
|
||||
|
||||
/// A context key with every value it carries in the request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextEntry {
|
||||
pub key: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
impl ContextEntry {
|
||||
/// Sent as `StringList` when the key has more than one value, `String`
|
||||
/// otherwise.
|
||||
pub fn is_list(&self) -> bool {
|
||||
self.values.len() > 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the simulation for `check`: the resource and every literal context
|
||||
/// value rendered with `p`, `OwnerId` as `user_id`, and `aws:userid` =
|
||||
/// `user_id` added. A key appearing more than once becomes one entry holding
|
||||
/// all its values (in order, without duplicates).
|
||||
pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> SimulationRequest {
|
||||
let mut context: Vec<ContextEntry> = Vec::new();
|
||||
let mut add = |key: &str, value: String| match context.iter_mut().find(|e| e.key == key) {
|
||||
Some(e) if e.values.contains(&value) => {}
|
||||
Some(e) => e.values.push(value),
|
||||
None => context.push(ContextEntry {
|
||||
key: key.to_string(),
|
||||
values: vec![value],
|
||||
}),
|
||||
};
|
||||
for (key, value) in check.context {
|
||||
let value = match value {
|
||||
ContextValue::OwnerId => user_id.to_string(),
|
||||
ContextValue::Literal(template) => render(template, p),
|
||||
};
|
||||
add(key, value);
|
||||
}
|
||||
add("aws:userid", user_id.to_string());
|
||||
SimulationRequest {
|
||||
principal: p.principal.to_string(),
|
||||
action: check.action.to_string(),
|
||||
resource: render(check.resource, p),
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
/// The read-only calls used when simulation isn't possible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Probe {
|
||||
EcsListClusters,
|
||||
SsmGetParametersByPath,
|
||||
SecretsListSecrets,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
const ALL: [Probe; 3] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::SecretsListSecrets,
|
||||
];
|
||||
|
||||
/// The probe standing in for a read check's service, if there is one.
|
||||
fn for_action(action: &str) -> Option<Probe> {
|
||||
match action.split_once(':')?.0 {
|
||||
"ecs" => Some(Probe::EcsListClusters),
|
||||
"ssm" => Some(Probe::SsmGetParametersByPath),
|
||||
"secretsmanager" => Some(Probe::SecretsListSecrets),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn action(self) -> &'static str {
|
||||
match self {
|
||||
Probe::EcsListClusters => "ecs:ListClusters",
|
||||
Probe::SsmGetParametersByPath => "ssm:GetParametersByPath",
|
||||
Probe::SecretsListSecrets => "secretsmanager:ListSecrets",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the permission check needs from AWS. The methods return `Send`
|
||||
/// futures so `check_permissions` is `Send` (a Tauri command needs that) for
|
||||
/// any implementation; an impl may still write them as `async fn`.
|
||||
pub trait PermissionApi: Send + Sync {
|
||||
/// The ARN to simulate for the caller's principal (`iam:GetRole` for a
|
||||
/// role session).
|
||||
fn resolve_principal(
|
||||
&self,
|
||||
principal: PrincipalArn,
|
||||
) -> impl Future<Output = Result<String, AwsError>> + Send;
|
||||
/// Whether the simulator says `allowed`.
|
||||
fn simulate(
|
||||
&self,
|
||||
request: &SimulationRequest,
|
||||
) -> impl Future<Output = Result<bool, AwsError>> + Send;
|
||||
fn probe(&self, probe: Probe) -> impl Future<Output = Result<(), AwsError>> + Send;
|
||||
}
|
||||
|
||||
/// The real thing, from a connection's `SdkConfig`.
|
||||
pub struct SdkPermissionApi {
|
||||
iam: aws_sdk_iam::Client,
|
||||
ecs: aws_sdk_ecs::Client,
|
||||
ssm: aws_sdk_ssm::Client,
|
||||
secrets: aws_sdk_secretsmanager::Client,
|
||||
}
|
||||
|
||||
impl SdkPermissionApi {
|
||||
pub fn new(cfg: &aws_config::SdkConfig) -> Self {
|
||||
// IAM throttles SimulatePrincipalPolicy hard: a full check (about a
|
||||
// hundred simulations) failed a third of its rows with "Rate
|
||||
// exceeded" under standard retries (verified live). Adaptive retry
|
||||
// backs off client-side once throttled, with room to retry.
|
||||
let iam = aws_sdk_iam::config::Builder::from(cfg)
|
||||
.retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10))
|
||||
.build();
|
||||
SdkPermissionApi {
|
||||
iam: aws_sdk_iam::Client::from_conf(iam),
|
||||
ecs: aws_sdk_ecs::Client::new(cfg),
|
||||
ssm: aws_sdk_ssm::Client::new(cfg),
|
||||
secrets: aws_sdk_secretsmanager::Client::new(cfg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionApi for SdkPermissionApi {
|
||||
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
|
||||
identity::resolve_principal(&self.iam, principal).await
|
||||
}
|
||||
|
||||
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
|
||||
use aws_sdk_iam::types::{ContextEntry as SdkEntry, ContextKeyTypeEnum};
|
||||
const ACTION: &str = "iam:SimulatePrincipalPolicy";
|
||||
let entries = request.context.iter().map(|e| {
|
||||
SdkEntry::builder()
|
||||
.context_key_name(&e.key)
|
||||
.set_context_key_values(Some(e.values.clone()))
|
||||
.context_key_type(if e.is_list() {
|
||||
ContextKeyTypeEnum::StringList
|
||||
} else {
|
||||
ContextKeyTypeEnum::String
|
||||
})
|
||||
.build()
|
||||
});
|
||||
let out = self
|
||||
.iam
|
||||
.simulate_principal_policy()
|
||||
.policy_source_arn(&request.principal)
|
||||
.action_names(&request.action)
|
||||
.resource_arns(&request.resource)
|
||||
.set_context_entries(Some(entries.collect()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
let decision = out
|
||||
.evaluation_results()
|
||||
.first()
|
||||
.map(|r| r.eval_decision().as_str())
|
||||
.ok_or_else(|| AwsError::Other {
|
||||
message: format!("{ACTION} returned no result for {}", request.action),
|
||||
})?;
|
||||
Ok(decision == "allowed")
|
||||
}
|
||||
|
||||
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
|
||||
let action = probe.action();
|
||||
let classify = |e: &(dyn std::error::Error + 'static)| classify_sdk_error(action, e);
|
||||
match probe {
|
||||
Probe::EcsListClusters => self
|
||||
.ecs
|
||||
.list_clusters()
|
||||
.max_results(1)
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
Probe::SsmGetParametersByPath => self
|
||||
.ssm
|
||||
.get_parameters_by_path()
|
||||
.path("/triple-c/environments")
|
||||
.max_results(1)
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
Probe::SecretsListSecrets => self
|
||||
.secrets
|
||||
.list_secrets()
|
||||
.max_results(1)
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
Allowed,
|
||||
Denied,
|
||||
Unverified,
|
||||
}
|
||||
|
||||
/// One row per action. An action checked against several resources (or
|
||||
/// with several contexts) is `Denied` if any of them is, else `Unverified`
|
||||
/// if any is, else `Allowed`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ActionResult {
|
||||
pub action: String,
|
||||
pub verdict: Verdict,
|
||||
/// Set when the row is `Unverified` because a call failed (throttling,
|
||||
/// network, ...): the error, as `AwsError` displays it. Such a row
|
||||
/// blocks its tier's claim; an `Unverified` row without a note (a write
|
||||
/// in probe mode, an unprobed read) does not.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TierResult {
|
||||
pub tier: Tier,
|
||||
pub results: Vec<ActionResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Method {
|
||||
Simulated,
|
||||
/// Simulation wasn't possible; `reason` says why.
|
||||
Probed {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Summary {
|
||||
EnvironmentAdmin,
|
||||
WorkspaceUser,
|
||||
Insufficient,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PermissionReport {
|
||||
pub identity: CallerIdentity,
|
||||
pub region: String,
|
||||
pub method: Method,
|
||||
pub summary: Summary,
|
||||
/// `[WorkspaceUser, EnvironmentAdmin]`.
|
||||
pub tiers: Vec<TierResult>,
|
||||
}
|
||||
|
||||
const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin];
|
||||
|
||||
/// One check's result, before checks are folded into per-action rows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Outcome {
|
||||
Allowed,
|
||||
Denied,
|
||||
/// Not checkable by design (a write in probe mode, an unprobed read).
|
||||
Unverified,
|
||||
/// The call failed for a reason that says nothing about the permission.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The worst of `outcomes`: Denied, then Failed (as `Unverified` with the
|
||||
/// first failure as its note), then Unverified, then Allowed.
|
||||
fn aggregate(outcomes: &[Outcome]) -> (Verdict, Option<String>) {
|
||||
if outcomes.contains(&Outcome::Denied) {
|
||||
return (Verdict::Denied, None);
|
||||
}
|
||||
if let Some(note) = outcomes.iter().find_map(|o| match o {
|
||||
Outcome::Failed(m) => Some(m.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return (Verdict::Unverified, Some(note));
|
||||
}
|
||||
if outcomes.contains(&Outcome::Unverified) {
|
||||
return (Verdict::Unverified, None);
|
||||
}
|
||||
(Verdict::Allowed, None)
|
||||
}
|
||||
|
||||
/// Folds per-check outcomes (in `checks(tier)` order) into one row per
|
||||
/// action, in first-appearance order.
|
||||
fn tier_result(tier: Tier, outcomes: Vec<Outcome>) -> TierResult {
|
||||
let mut rows: Vec<(&'static str, Vec<Outcome>)> = Vec::new();
|
||||
for (check, outcome) in checks(tier).iter().zip(outcomes) {
|
||||
match rows.iter_mut().find(|(a, _)| *a == check.action) {
|
||||
Some((_, os)) => os.push(outcome),
|
||||
None => rows.push((check.action, vec![outcome])),
|
||||
}
|
||||
}
|
||||
TierResult {
|
||||
tier,
|
||||
results: rows
|
||||
.into_iter()
|
||||
.map(|(action, os)| {
|
||||
let (verdict, note) = aggregate(&os);
|
||||
ActionResult {
|
||||
action: action.to_string(),
|
||||
verdict,
|
||||
note,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tier is claimed when none of its rows is `Denied` or failed (has a
|
||||
/// note). Design-level `Unverified` rows don't block. A probed run never
|
||||
/// claims the admin tier (R15): it is nearly all writes, which probes can't
|
||||
/// see, so "nothing denied" would mean nothing.
|
||||
fn summary(tiers: &[TierResult], probed: bool) -> Summary {
|
||||
let clear = |t: Tier| {
|
||||
tiers.iter().filter(|r| r.tier == t).all(|r| {
|
||||
r.results
|
||||
.iter()
|
||||
.all(|a| a.verdict != Verdict::Denied && a.note.is_none())
|
||||
})
|
||||
};
|
||||
if !clear(Tier::WorkspaceUser) {
|
||||
Summary::Insufficient
|
||||
} else if !probed && clear(Tier::EnvironmentAdmin) {
|
||||
Summary::EnvironmentAdmin
|
||||
} else {
|
||||
Summary::WorkspaceUser
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that mean the credentials themselves don't work: the UI acts on
|
||||
/// these (sign in, re-enter keys) rather than showing a report.
|
||||
fn is_credential_error(e: &AwsError) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
AwsError::SsoExpired { .. }
|
||||
| AwsError::InvalidCredentials { .. }
|
||||
| AwsError::ProfileNotFound { .. }
|
||||
| AwsError::KeysMissing
|
||||
)
|
||||
}
|
||||
|
||||
/// Which tiers the caller's credentials can act as, in `region`.
|
||||
///
|
||||
/// Simulates every check of both tiers against the caller's principal. It
|
||||
/// falls back to probes when the principal can't be simulated (root,
|
||||
/// federated user), resolving it is denied (`iam:GetRole`), or the first
|
||||
/// simulation is denied. In probe mode a read check whose service has a
|
||||
/// probe takes that probe's result; the check behind the fallback (the
|
||||
/// caller's own `iam:GetRole` / `iam:SimulatePrincipalPolicy`) is `Denied`;
|
||||
/// every other check is `Unverified`.
|
||||
///
|
||||
/// A credential error (SSO expired, invalid or missing credentials) from
|
||||
/// any call is returned as `Err`, as is any other failure resolving the
|
||||
/// principal. Other failures of individual checks (throttling, network)
|
||||
/// make that row `Unverified` with a note, which blocks its tier's claim.
|
||||
/// See [`summary`] for the claim rules.
|
||||
pub async fn check_permissions<A: PermissionApi>(
|
||||
api: &A,
|
||||
identity: &CallerIdentity,
|
||||
region: &str,
|
||||
) -> Result<PermissionReport, AwsError> {
|
||||
let (method, outcomes) = match simulate_all(api, identity, region).await? {
|
||||
Simulation::Done(outcomes) => (Method::Simulated, outcomes),
|
||||
Simulation::Fallback { reason, denied } => (
|
||||
Method::Probed { reason },
|
||||
probe_all(api, denied.as_deref()).await?,
|
||||
),
|
||||
};
|
||||
let failed: Vec<&str> = outcomes
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|o| match o {
|
||||
Outcome::Failed(m) => Some(m.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if let Some(first) = failed.first() {
|
||||
let total: usize = outcomes.iter().map(Vec::len).sum();
|
||||
log::warn!(
|
||||
"AWS permission check: {} of {total} checks could not be evaluated (first: {first})",
|
||||
failed.len()
|
||||
);
|
||||
}
|
||||
let probed = matches!(method, Method::Probed { .. });
|
||||
let tiers: Vec<TierResult> = TIERS
|
||||
.into_iter()
|
||||
.zip(outcomes)
|
||||
.map(|(tier, os)| tier_result(tier, os))
|
||||
.collect();
|
||||
Ok(PermissionReport {
|
||||
identity: identity.clone(),
|
||||
region: region.to_string(),
|
||||
method,
|
||||
summary: summary(&tiers, probed),
|
||||
tiers,
|
||||
})
|
||||
}
|
||||
|
||||
enum Simulation {
|
||||
Done([Vec<Outcome>; 2]),
|
||||
/// Simulation isn't possible. `denied` is the action whose denial
|
||||
/// caused it, if one did.
|
||||
Fallback {
|
||||
reason: String,
|
||||
denied: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Simulation {
|
||||
fn fallback(e: AwsError) -> Self {
|
||||
let denied = match &e {
|
||||
AwsError::AccessDenied { action, .. } => Some(action.clone()),
|
||||
_ => None,
|
||||
};
|
||||
Simulation::Fallback {
|
||||
reason: e.to_string(),
|
||||
denied,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn simulated_outcome(r: Result<bool, AwsError>) -> Result<Outcome, AwsError> {
|
||||
match r {
|
||||
Ok(true) => Ok(Outcome::Allowed),
|
||||
Ok(false) => Ok(Outcome::Denied),
|
||||
Err(e) if is_credential_error(&e) => Err(e),
|
||||
Err(e) => Ok(Outcome::Failed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-tier outcomes by simulation, or why simulation isn't possible.
|
||||
async fn simulate_all<A: PermissionApi>(
|
||||
api: &A,
|
||||
identity: &CallerIdentity,
|
||||
region: &str,
|
||||
) -> Result<Simulation, AwsError> {
|
||||
let principal = match principal_for_simulation(&identity.arn) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(Simulation::fallback(e)),
|
||||
};
|
||||
let principal = match api.resolve_principal(principal).await {
|
||||
Ok(p) => p,
|
||||
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let p = Placeholders {
|
||||
region,
|
||||
account: &identity.account,
|
||||
principal: &principal,
|
||||
};
|
||||
let mut requests = TIERS
|
||||
.into_iter()
|
||||
.flat_map(|t| checks(t).iter())
|
||||
.map(|c| simulation_request(c, &p, &identity.user_id))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter();
|
||||
let Some(first) = requests.next() else {
|
||||
return Ok(Simulation::Done([Vec::new(), Vec::new()]));
|
||||
};
|
||||
// The first simulation alone: if the caller may not simulate at all,
|
||||
// that's the moment to fall back, before spending the rest.
|
||||
let first = match api.simulate(&first).await {
|
||||
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
|
||||
r => simulated_outcome(r)?,
|
||||
};
|
||||
// Owned requests: a closure over borrowed ones makes the future's
|
||||
// `Send` unprovable (rustc's higher-ranked closure limitation).
|
||||
let rest: Vec<Outcome> = stream::iter(requests)
|
||||
.map(|r| async move { simulated_outcome(api.simulate(&r).await) })
|
||||
.buffered(CONCURRENCY)
|
||||
.try_collect()
|
||||
.await?;
|
||||
let mut all = std::iter::once(first).chain(rest);
|
||||
Ok(Simulation::Done(
|
||||
TIERS.map(|t| all.by_ref().take(checks(t).len()).collect()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Per-tier outcomes from the three probes. `denied` is the action whose
|
||||
/// denial forced probing; the caller's own-principal checks of it are
|
||||
/// `Denied`.
|
||||
async fn probe_all<A: PermissionApi>(
|
||||
api: &A,
|
||||
denied: Option<&str>,
|
||||
) -> Result<[Vec<Outcome>; 2], AwsError> {
|
||||
let results =
|
||||
futures_util::future::join_all(Probe::ALL.map(|p| async move { (p, api.probe(p).await) }))
|
||||
.await;
|
||||
if let Some((_, Err(e))) = results
|
||||
.iter()
|
||||
.find(|(_, r)| matches!(r, Err(e) if is_credential_error(e)))
|
||||
{
|
||||
return Err(e.clone());
|
||||
}
|
||||
let probe_outcome = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) {
|
||||
Some((_, Ok(()))) => Outcome::Allowed,
|
||||
Some((_, Err(AwsError::AccessDenied { .. }))) => Outcome::Denied,
|
||||
Some((_, Err(e))) => Outcome::Failed(e.to_string()),
|
||||
None => Outcome::Unverified,
|
||||
};
|
||||
Ok(TIERS.map(|t| {
|
||||
checks(t)
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if denied == Some(c.action) && c.resource == "{principal}" {
|
||||
return Outcome::Denied;
|
||||
}
|
||||
match Probe::for_action(c.action) {
|
||||
Some(probe) if !c.write => probe_outcome(probe),
|
||||
_ => Outcome::Unverified,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,630 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
use crate::aws::policies::{checks, Check, ContextValue, Placeholders, Tier};
|
||||
|
||||
const USER_ID: &str = "AROAEXAMPLEID:alice@example.com";
|
||||
const ROLE_ARN: &str =
|
||||
"arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Ws_abc";
|
||||
|
||||
fn identity() -> CallerIdentity {
|
||||
CallerIdentity {
|
||||
account: "111122223333".into(),
|
||||
arn: "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Ws_abc/alice@example.com"
|
||||
.into(),
|
||||
user_id: USER_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_PROBES: [Probe; 3] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::SecretsListSecrets,
|
||||
];
|
||||
|
||||
/// A simulation call as the fake saw it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Simulated {
|
||||
principal: String,
|
||||
action: String,
|
||||
resource: String,
|
||||
context: Vec<ContextEntry>,
|
||||
}
|
||||
|
||||
struct FakeApi {
|
||||
allow: HashSet<&'static str>,
|
||||
simulate_denied: bool,
|
||||
get_role_denied: bool,
|
||||
/// Returned by `resolve_principal` (after `get_role_denied`).
|
||||
resolve_error: Option<AwsError>,
|
||||
/// Returned by `simulate` for the named action.
|
||||
simulate_errors: Vec<(&'static str, AwsError)>,
|
||||
probes_ok: HashSet<Probe>,
|
||||
/// Returned by every probe not in `probes_ok` (default: AccessDenied).
|
||||
probe_error: Option<AwsError>,
|
||||
simulated: Mutex<Vec<Simulated>>,
|
||||
probed: Mutex<Vec<Probe>>,
|
||||
}
|
||||
|
||||
impl FakeApi {
|
||||
fn allowing(tiers: &[Tier]) -> Self {
|
||||
let allow = tiers
|
||||
.iter()
|
||||
.flat_map(|t| checks(*t).iter().map(|c| c.action))
|
||||
.collect();
|
||||
FakeApi {
|
||||
allow,
|
||||
simulate_denied: false,
|
||||
get_role_denied: false,
|
||||
resolve_error: None,
|
||||
simulate_errors: Vec::new(),
|
||||
probes_ok: ALL_PROBES.into_iter().collect(),
|
||||
probe_error: None,
|
||||
simulated: Mutex::new(Vec::new()),
|
||||
probed: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn simulated(&self) -> Vec<Simulated> {
|
||||
self.simulated.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionApi for FakeApi {
|
||||
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
|
||||
if self.get_role_denied {
|
||||
return Err(AwsError::AccessDenied {
|
||||
action: "iam:GetRole".into(),
|
||||
message: "not authorized to perform: iam:GetRole".into(),
|
||||
});
|
||||
}
|
||||
if let Some(e) = &self.resolve_error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(match principal {
|
||||
PrincipalArn::Exact(arn) => arn,
|
||||
PrincipalArn::AssumedRole { .. } => ROLE_ARN.into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
|
||||
self.simulated.lock().unwrap().push(Simulated {
|
||||
principal: request.principal.clone(),
|
||||
action: request.action.clone(),
|
||||
resource: request.resource.clone(),
|
||||
context: request.context.clone(),
|
||||
});
|
||||
if self.simulate_denied {
|
||||
return Err(AwsError::AccessDenied {
|
||||
action: "iam:SimulatePrincipalPolicy".into(),
|
||||
message: "not authorized to perform: iam:SimulatePrincipalPolicy".into(),
|
||||
});
|
||||
}
|
||||
if let Some((_, e)) = self
|
||||
.simulate_errors
|
||||
.iter()
|
||||
.find(|(a, _)| *a == request.action)
|
||||
{
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(self.allow.contains(request.action.as_str()))
|
||||
}
|
||||
|
||||
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
|
||||
self.probed.lock().unwrap().push(probe);
|
||||
if self.probes_ok.contains(&probe) {
|
||||
Ok(())
|
||||
} else if let Some(e) = &self.probe_error {
|
||||
Err(e.clone())
|
||||
} else {
|
||||
Err(AwsError::AccessDenied {
|
||||
action: format!("{probe:?}"),
|
||||
message: "denied".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_run(api: &FakeApi, id: &CallerIdentity) -> Result<PermissionReport, AwsError> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(check_permissions(api, id, "eu-west-1"))
|
||||
}
|
||||
|
||||
fn run(api: &FakeApi) -> PermissionReport {
|
||||
try_run(api, &identity()).unwrap()
|
||||
}
|
||||
|
||||
fn row<'a>(report: &'a PermissionReport, t: Tier, action: &str) -> &'a ActionResult {
|
||||
tier(report, t)
|
||||
.results
|
||||
.iter()
|
||||
.find(|r| r.action == action)
|
||||
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
|
||||
}
|
||||
|
||||
fn throttled() -> AwsError {
|
||||
AwsError::Other {
|
||||
message: "Rate exceeded (Throttling)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tier(report: &PermissionReport, t: Tier) -> &TierResult {
|
||||
report.tiers.iter().find(|r| r.tier == t).unwrap()
|
||||
}
|
||||
|
||||
fn verdict(report: &PermissionReport, t: Tier, action: &str) -> Verdict {
|
||||
tier(report, t)
|
||||
.results
|
||||
.iter()
|
||||
.find(|r| r.action == action)
|
||||
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
|
||||
.verdict
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_workspace_policy_reports_workspace_user() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert_eq!(report.region, "eu-west-1");
|
||||
assert_eq!(report.identity, identity());
|
||||
let tiers: Vec<Tier> = report.tiers.iter().map(|t| t.tier).collect();
|
||||
assert_eq!(tiers, [Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
assert!(tier(&report, Tier::WorkspaceUser)
|
||||
.results
|
||||
.iter()
|
||||
.all(|r| r.verdict == Verdict::Allowed));
|
||||
// Every check was simulated against the resolved role, not the session.
|
||||
let sims = api.simulated();
|
||||
let total = checks(Tier::WorkspaceUser).len() + checks(Tier::EnvironmentAdmin).len();
|
||||
assert_eq!(sims.len(), total);
|
||||
assert!(sims.iter().all(|s| s.principal == ROLE_ARN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_and_workspace_policies_report_environment_admin() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_action_reports_insufficient_and_names_it() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.allow.remove("iam:PassRole");
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::WorkspaceUser, "iam:PassRole"),
|
||||
Verdict::Denied
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::WorkspaceUser, "ecs:RunTask"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_admin_gap_still_reports_workspace_user() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.allow.remove("ec2:CreateVpc");
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc"),
|
||||
Verdict::Denied
|
||||
);
|
||||
}
|
||||
|
||||
/// An action checked against several resources is one row; any denial wins.
|
||||
#[test]
|
||||
fn an_action_checked_twice_is_one_row_and_denied_if_either_is() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let report = run(&api);
|
||||
let rows = tier(&report, Tier::WorkspaceUser)
|
||||
.results
|
||||
.iter()
|
||||
.filter(|r| r.action == "ssm:StartSession")
|
||||
.count();
|
||||
assert_eq!(rows, 1);
|
||||
let failed = || Outcome::Failed("Throttling".into());
|
||||
assert_eq!(
|
||||
aggregate(&[Outcome::Allowed, Outcome::Denied, failed()]),
|
||||
(Verdict::Denied, None)
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate(&[Outcome::Allowed, Outcome::Unverified, failed()]),
|
||||
(Verdict::Unverified, Some("Throttling".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate(&[Outcome::Allowed, Outcome::Unverified]),
|
||||
(Verdict::Unverified, None)
|
||||
);
|
||||
assert_eq!(aggregate(&[Outcome::Allowed]), (Verdict::Allowed, None));
|
||||
}
|
||||
|
||||
fn context_value<'a>(s: &'a Simulated, key: &str) -> Option<&'a ContextEntry> {
|
||||
s.context.iter().find(|e| e.key == key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_context_is_sent_with_owner_scoped_checks() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
run(&api);
|
||||
let sims = api.simulated();
|
||||
let run_task = sims.iter().find(|s| s.action == "ecs:RunTask").unwrap();
|
||||
let owner = context_value(run_task, "aws:RequestTag/triple-c:owner").unwrap();
|
||||
assert_eq!(owner.values, [USER_ID]);
|
||||
// Literals are rendered like resources.
|
||||
let cluster = context_value(run_task, "ecs:cluster").unwrap();
|
||||
assert_eq!(
|
||||
cluster.values,
|
||||
["arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-probe"]
|
||||
);
|
||||
assert_eq!(
|
||||
run_task.resource,
|
||||
"arn:aws:ecs:eu-west-1:111122223333:task-definition/triple-c-probe:1"
|
||||
);
|
||||
// aws:userid on every simulation.
|
||||
for s in &sims {
|
||||
let userid = context_value(s, "aws:userid")
|
||||
.unwrap_or_else(|| panic!("{} has no aws:userid", s.action));
|
||||
assert_eq!(userid.values, [USER_ID], "{}", s.action);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_principal_placeholder_is_the_resolved_role() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
run(&api);
|
||||
let sims = api.simulated();
|
||||
let get_role = sims
|
||||
.iter()
|
||||
.find(|s| s.action == "iam:GetRole" && s.resource == ROLE_ARN)
|
||||
.expect("iam:GetRole on the caller's own role");
|
||||
// The policies name a user's own ARN by `${aws:username}`.
|
||||
assert_eq!(
|
||||
context_value(get_role, "aws:username").unwrap().values,
|
||||
[ROLE_ARN.rsplit('/').next().unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_context_key_is_one_string_list_entry() {
|
||||
static CTX: &[(&str, ContextValue)] = &[
|
||||
("aws:TagKeys", ContextValue::Literal("triple-c:owner")),
|
||||
(
|
||||
"ecs:cluster",
|
||||
ContextValue::Literal("arn:{region}:{account}"),
|
||||
),
|
||||
("aws:TagKeys", ContextValue::Literal("triple-c:component")),
|
||||
("aws:TagKeys", ContextValue::OwnerId),
|
||||
];
|
||||
let check = Check {
|
||||
action: "ecs:TagResource",
|
||||
resource: "arn:aws:ecs:{region}:{account}:task/x",
|
||||
context: CTX,
|
||||
write: true,
|
||||
};
|
||||
let p = Placeholders {
|
||||
region: "eu-west-1",
|
||||
account: "111122223333",
|
||||
principal: ROLE_ARN,
|
||||
};
|
||||
let req = simulation_request(&check, &p, USER_ID);
|
||||
assert_eq!(req.principal, ROLE_ARN);
|
||||
assert_eq!(req.action, "ecs:TagResource");
|
||||
assert_eq!(req.resource, "arn:aws:ecs:eu-west-1:111122223333:task/x");
|
||||
assert_eq!(
|
||||
req.context,
|
||||
[
|
||||
ContextEntry {
|
||||
key: "aws:TagKeys".into(),
|
||||
values: vec![
|
||||
"triple-c:owner".into(),
|
||||
"triple-c:component".into(),
|
||||
USER_ID.into()
|
||||
],
|
||||
},
|
||||
ContextEntry {
|
||||
key: "ecs:cluster".into(),
|
||||
values: vec!["arn:eu-west-1:111122223333".into()],
|
||||
},
|
||||
ContextEntry {
|
||||
key: "aws:userid".into(),
|
||||
values: vec![USER_ID.into()],
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(req.context[0].is_list());
|
||||
assert!(!req.context[1].is_list());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_naming_aws_userid_itself_is_not_sent_it_twice() {
|
||||
static CTX: &[(&str, ContextValue)] = &[("aws:userid", ContextValue::OwnerId)];
|
||||
let check = Check {
|
||||
action: "sts:GetCallerIdentity",
|
||||
resource: "*",
|
||||
context: CTX,
|
||||
write: false,
|
||||
};
|
||||
let p = Placeholders {
|
||||
region: "r",
|
||||
account: "a",
|
||||
principal: "p",
|
||||
};
|
||||
let req = simulation_request(&check, &p, USER_ID);
|
||||
assert_eq!(
|
||||
req.context,
|
||||
[ContextEntry {
|
||||
key: "aws:userid".into(),
|
||||
values: vec![USER_ID.into()],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.simulate_denied = true;
|
||||
let report = run(&api);
|
||||
match &report.method {
|
||||
Method::Probed { reason } => {
|
||||
assert!(reason.contains("iam:SimulatePrincipalPolicy"), "{reason}")
|
||||
}
|
||||
m => panic!("expected Probed, got {m:?}"),
|
||||
}
|
||||
// Only the first simulation was tried.
|
||||
assert_eq!(api.simulated().len(), 1);
|
||||
let mut probed = api.probed.lock().unwrap().clone();
|
||||
probed.sort_by_key(|p| format!("{p:?}"));
|
||||
let mut all = ALL_PROBES.to_vec();
|
||||
all.sort_by_key(|p| format!("{p:?}"));
|
||||
assert_eq!(probed, all);
|
||||
|
||||
let ws = Tier::WorkspaceUser;
|
||||
assert_eq!(verdict(&report, ws, "ecs:RunTask"), Verdict::Unverified);
|
||||
assert_eq!(verdict(&report, ws, "iam:PassRole"), Verdict::Unverified);
|
||||
// Reads with a probe get its result.
|
||||
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Allowed);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "ecs:DescribeClusters"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "ssm:GetParametersByPath"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "secretsmanager:ListSecrets"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
// Reads with no probe are unverified.
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "logs:FilterLogEvents"),
|
||||
Verdict::Unverified
|
||||
);
|
||||
assert_eq!(verdict(&report, ws, "iam:GetRole"), Verdict::Unverified);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "sts:GetCallerIdentity"),
|
||||
Verdict::Unverified
|
||||
);
|
||||
// The fallback itself is evidence: the caller may not simulate itself,
|
||||
// which both tiers need.
|
||||
for t in [ws, Tier::EnvironmentAdmin] {
|
||||
assert_eq!(
|
||||
verdict(&report, t, "iam:SimulatePrincipalPolicy"),
|
||||
Verdict::Denied
|
||||
);
|
||||
}
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denied_get_role_falls_back_to_probes() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.get_role_denied = true;
|
||||
let report = run(&api);
|
||||
match &report.method {
|
||||
Method::Probed { reason } => assert!(reason.contains("iam:GetRole"), "{reason}"),
|
||||
m => panic!("expected Probed, got {m:?}"),
|
||||
}
|
||||
assert!(api.simulated().is_empty());
|
||||
assert_eq!(api.probed.lock().unwrap().len(), ALL_PROBES.len());
|
||||
// Denied on the caller's own role: direct evidence.
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::WorkspaceUser, "iam:GetRole"),
|
||||
Verdict::Denied
|
||||
);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsimulatable_principal_falls_back_to_probes() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let mut id = identity();
|
||||
id.arn = "arn:aws:iam::111122223333:root".into();
|
||||
let report = try_run(&api, &id).unwrap();
|
||||
match &report.method {
|
||||
Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"),
|
||||
m => panic!("expected Probed, got {m:?}"),
|
||||
}
|
||||
assert!(api.simulated().is_empty());
|
||||
}
|
||||
|
||||
/// R15: the admin tier is nearly all writes, so a probed run can't vouch
|
||||
/// for it — every probe passing still caps at WorkspaceUser.
|
||||
#[test]
|
||||
fn a_probed_run_never_claims_environment_admin() {
|
||||
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let mut id = identity();
|
||||
id.arn = "arn:aws:iam::111122223333:root".into();
|
||||
let report = try_run(&api, &id).unwrap();
|
||||
assert!(matches!(report.method, Method::Probed { .. }));
|
||||
assert!(tier(&report, Tier::EnvironmentAdmin)
|
||||
.results
|
||||
.iter()
|
||||
.all(|r| r.verdict != Verdict::Denied));
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throttled_simulations_block_the_tier_and_carry_notes() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.simulate_errors = vec![("ec2:CreateVpc", throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
let r = row(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc");
|
||||
assert_eq!(r.verdict, Verdict::Unverified);
|
||||
assert!(r.note.as_deref().unwrap().contains("Throttling"), "{r:?}");
|
||||
// Allowed rows carry no note.
|
||||
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
|
||||
// In the workspace tier, it blocks everything.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.simulate_errors = vec![("ecs:RunTask", throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
assert!(row(&report, Tier::WorkspaceUser, "ecs:RunTask")
|
||||
.note
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_throttled_first_simulation_is_a_note_not_a_fallback() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let first = checks(Tier::WorkspaceUser)[0].action;
|
||||
api.simulate_errors = vec![(first, throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert!(row(&report, Tier::WorkspaceUser, first).note.is_some());
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_sso_session_mid_check_is_an_error() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
let sso = AwsError::SsoExpired {
|
||||
profile: "dev".into(),
|
||||
};
|
||||
api.simulate_errors = vec![("logs:FilterLogEvents", sso.clone())];
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), sso);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_errors_anywhere_are_errors() {
|
||||
let bad = AwsError::InvalidCredentials {
|
||||
message: "ExpiredToken".into(),
|
||||
};
|
||||
// From resolving the principal.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.resolve_error = Some(bad.clone());
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
|
||||
// From the first simulation.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.simulate_errors = vec![(checks(Tier::WorkspaceUser)[0].action, bad.clone())];
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
|
||||
// From a probe.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.get_role_denied = true;
|
||||
api.probes_ok.remove(&Probe::SecretsListSecrets);
|
||||
api.probe_error = Some(AwsError::KeysMissing);
|
||||
assert_eq!(
|
||||
try_run(&api, &identity()).unwrap_err(),
|
||||
AwsError::KeysMissing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transport_error_resolving_the_principal_is_an_error() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let net = AwsError::Other {
|
||||
message: "dispatch failure: connection refused".into(),
|
||||
};
|
||||
api.resolve_error = Some(net.clone());
|
||||
assert_eq!(try_run(&api, &identity()).unwrap_err(), net);
|
||||
assert!(api.probed.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_probe_marks_its_reads_denied() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.simulate_denied = true;
|
||||
api.probes_ok.remove(&Probe::EcsListClusters);
|
||||
let report = run(&api);
|
||||
let ws = Tier::WorkspaceUser;
|
||||
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Denied);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "ecs:DescribeClusters"),
|
||||
Verdict::Denied
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "secretsmanager:ListSecrets"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_probe_failing_for_another_reason_leaves_its_reads_unverified_and_blocks() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
let mut id = identity();
|
||||
id.arn = "arn:aws:iam::111122223333:root".into();
|
||||
api.probes_ok.remove(&Probe::EcsListClusters);
|
||||
api.probe_error = Some(AwsError::Other {
|
||||
message: "connection reset".into(),
|
||||
});
|
||||
let report = try_run(&api, &id).unwrap();
|
||||
let r = row(&report, Tier::WorkspaceUser, "ecs:ListClusters");
|
||||
assert_eq!(r.verdict, Verdict::Unverified);
|
||||
assert!(r.note.as_deref().unwrap().contains("connection reset"));
|
||||
// Design-level unverified rows carry no note.
|
||||
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_serializes_for_the_ui() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.get_role_denied = true;
|
||||
let v = serde_json::to_value(run(&api)).unwrap();
|
||||
// Probed because iam:GetRole is denied, which the workspace tier needs.
|
||||
assert_eq!(v["summary"], "insufficient");
|
||||
assert!(v["tiers"][0]["results"][0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("note"));
|
||||
assert_eq!(v["tiers"][0]["tier"], "workspace_user");
|
||||
assert!(v["method"]["probed"]["reason"].is_string(), "{v}");
|
||||
assert!(["allowed", "denied", "unverified"]
|
||||
.contains(&v["tiers"][0]["results"][0]["verdict"].as_str().unwrap()));
|
||||
}
|
||||
|
||||
/// Tauri commands need a `Send` future.
|
||||
#[allow(dead_code)]
|
||||
fn check_permissions_is_send() {
|
||||
fn assert_send<T: Send>(_: T) {}
|
||||
fn with<A: PermissionApi>(api: &A, id: &CallerIdentity) {
|
||||
assert_send(check_permissions(api, id, "r"));
|
||||
}
|
||||
let _ = with::<SdkPermissionApi>;
|
||||
}
|
||||
|
||||
/// D8: CloudFormation left the policies, so nothing probes it either.
|
||||
#[test]
|
||||
fn no_probe_is_cloudformation() {
|
||||
assert_eq!(Probe::ALL, ALL_PROBES);
|
||||
for p in Probe::ALL {
|
||||
assert!(!p.action().starts_with("cloudformation:"), "{p:?}");
|
||||
assert_eq!(Probe::for_action(p.action()), Some(p));
|
||||
}
|
||||
assert_eq!(Probe::for_action("cloudformation:DescribeStacks"), None);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,303 @@
|
||||
//! The test-only IAM evaluator the policy drift guards use (moved here from
|
||||
//! `policies.rs` so the Terraform-module tests can ask it too). It is
|
||||
//! deliberately small: the operators the shipped policies use and nothing
|
||||
//! else; an unknown operator fails the test rather than being ignored. It is
|
||||
//! not IAM -- `live_tests.rs` and the live runs are the proof.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::policies::{render, Check, ContextValue, Placeholders};
|
||||
|
||||
pub(crate) const AT: Placeholders = Placeholders {
|
||||
region: "us-west-2",
|
||||
account: "111122223333",
|
||||
principal: "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Dev_0123456789abcdef",
|
||||
};
|
||||
pub(crate) const ME: &str = "AROAEXAMPLEME:alice@example.com";
|
||||
pub(crate) const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com";
|
||||
|
||||
/// What [`Request::resolve`] turns IAM's `${*}` into: a literal `*` in a
|
||||
/// pattern, which [`glob`] matches only against a `*`.
|
||||
const LITERAL_STAR: char = '\u{e000}';
|
||||
|
||||
/// IAM wildcard match: `*` is any run of characters (including `:` and
|
||||
/// `/`), `?` is exactly one; [`LITERAL_STAR`] is a plain `*`.
|
||||
pub(crate) fn glob(pattern: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let (mut pi, mut ti) = (0, 0);
|
||||
let (mut star, mut mark) = (None, 0);
|
||||
while ti < t.len() {
|
||||
if pi < p.len()
|
||||
&& (p[pi] == '?' || p[pi] == t[ti] || (p[pi] == LITERAL_STAR && t[ti] == '*'))
|
||||
{
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star = Some(pi);
|
||||
mark = ti;
|
||||
pi += 1;
|
||||
} else if let Some(s) = star {
|
||||
pi = s + 1;
|
||||
mark += 1;
|
||||
ti = mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && p[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
pub(crate) fn parse(name: &str, json: &str) -> Value {
|
||||
serde_json::from_str(json).unwrap_or_else(|e| panic!("{name} is not JSON: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn statements_of(doc: &Value) -> Vec<Value> {
|
||||
doc["Statement"]
|
||||
.as_array()
|
||||
.expect("policy has no Statement array")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn effect(stmt: &Value) -> &str {
|
||||
stmt["Effect"].as_str().unwrap_or("<no Effect>")
|
||||
}
|
||||
|
||||
/// `Action` / `Resource` / condition values: a string or an array of them.
|
||||
pub(crate) fn strings(v: &Value) -> Vec<String> {
|
||||
match v {
|
||||
Value::String(s) => vec![s.clone()],
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.map(|x| x.as_str().expect("expected a string").to_string())
|
||||
.collect(),
|
||||
other => panic!("expected a string or array, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn actions(stmt: &Value) -> Vec<String> {
|
||||
strings(&stmt["Action"])
|
||||
}
|
||||
|
||||
pub(crate) fn resources(stmt: &Value) -> Vec<String> {
|
||||
strings(&stmt["Resource"])
|
||||
}
|
||||
|
||||
pub(crate) fn sid(stmt: &Value) -> &str {
|
||||
stmt["Sid"].as_str().unwrap_or("<no Sid>")
|
||||
}
|
||||
|
||||
pub(crate) fn rendered(check: &Check) -> String {
|
||||
render(check.resource, &AT)
|
||||
}
|
||||
|
||||
/// The request: who calls, and whose the resource / request tag is.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Request<'a> {
|
||||
pub check: &'a Check,
|
||||
/// `${aws:userid}` of the caller.
|
||||
pub caller: &'a str,
|
||||
/// What `OwnerId` context entries carry.
|
||||
pub owner: &'a str,
|
||||
}
|
||||
|
||||
impl Request<'_> {
|
||||
pub(crate) fn context_value(&self, key: &str) -> Option<String> {
|
||||
self.context_values(key).into_iter().next()
|
||||
}
|
||||
|
||||
/// All values of a (possibly multi-valued) key.
|
||||
pub(crate) fn context_values(&self, key: &str) -> Vec<String> {
|
||||
self.check
|
||||
.context
|
||||
.iter()
|
||||
.filter(|(k, _)| *k == key)
|
||||
.map(|(_, v)| match v {
|
||||
ContextValue::OwnerId => self.owner.to_string(),
|
||||
ContextValue::Literal(l) => render(l, &AT),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolves `${…}` policy variables the way the simulator does: from
|
||||
/// the request context. An unresolvable variable makes the string
|
||||
/// match nothing (`None`).
|
||||
pub(crate) fn resolve(&self, s: &str) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find("${") {
|
||||
out.push_str(&rest[..start]);
|
||||
let end = rest[start..].find('}').expect("unterminated ${") + start;
|
||||
let name = &rest[start + 2..end];
|
||||
let value = if name == "*" {
|
||||
Some(LITERAL_STAR.to_string())
|
||||
} else if name == "aws:userid" {
|
||||
Some(self.caller.to_string())
|
||||
} else {
|
||||
self.context_value(name)
|
||||
};
|
||||
out.push_str(&value?);
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Statements that name the action and one of whose resources matches
|
||||
/// the rendered resource.
|
||||
pub(crate) fn matching<'s>(stmts: &'s [Value], req: &Request) -> Vec<&'s Value> {
|
||||
let resource = rendered(req.check);
|
||||
stmts
|
||||
.iter()
|
||||
.filter(|s| actions(s).iter().any(|a| glob(a, req.check.action)))
|
||||
.filter(|s| {
|
||||
resources(s)
|
||||
.iter()
|
||||
.filter_map(|r| req.resolve(r))
|
||||
.any(|r| glob(&r, &resource))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A deliberately small IAM condition evaluator: the operators the
|
||||
/// shipped policies use, and nothing else (an unknown operator fails the
|
||||
/// test rather than being ignored).
|
||||
pub(crate) fn conditions_hold(stmt: &Value, req: &Request) -> bool {
|
||||
let Some(cond) = stmt.get("Condition") else {
|
||||
return true;
|
||||
};
|
||||
let cond = cond.as_object().expect("Condition is an object");
|
||||
cond.iter().all(|(op, entries)| {
|
||||
let entries = entries.as_object().expect("condition block is an object");
|
||||
// ForAllValues: every value of the (multi-valued) key matches
|
||||
// one of the policy's values. Vacuously true when the key is
|
||||
// absent, which is why statements pair it with a required key.
|
||||
if let Some(base) = op.strip_prefix("ForAllValues:") {
|
||||
assert_eq!(
|
||||
base,
|
||||
"StringEquals",
|
||||
"{}: unsupported ForAllValues operator",
|
||||
sid(stmt)
|
||||
);
|
||||
return entries.iter().all(|(key, want)| {
|
||||
let allowed: Vec<String> = strings(want)
|
||||
.iter()
|
||||
.filter_map(|p| req.resolve(p))
|
||||
.collect();
|
||||
req.context_values(key).iter().all(|v| allowed.contains(v))
|
||||
});
|
||||
}
|
||||
if op == "Null" {
|
||||
return entries.iter().all(|(key, want)| {
|
||||
let absent = req.context_value(key).is_none();
|
||||
match want.as_str() {
|
||||
Some("true") => absent,
|
||||
Some("false") => !absent,
|
||||
_ => panic!("{}: Null takes \"true\" or \"false\"", sid(stmt)),
|
||||
}
|
||||
});
|
||||
}
|
||||
let (base, if_exists) = match op.strip_suffix("IfExists") {
|
||||
Some(b) => (b, true),
|
||||
None => (op.as_str(), false),
|
||||
};
|
||||
// A negated operator holds when the key is absent, and
|
||||
// otherwise when no value matches.
|
||||
let (matches, negated): (fn(&str, &str) -> bool, bool) = match base {
|
||||
"StringEquals" => (|p, v| p == v, false),
|
||||
"StringLike" | "ArnLike" => (glob, false),
|
||||
"StringNotEquals" => (|p, v| p == v, true),
|
||||
"StringNotLike" => (glob, true),
|
||||
other => panic!("{}: unsupported condition operator {other}", sid(stmt)),
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.all(|(key, want)| match req.context_value(key) {
|
||||
None => if_exists || negated,
|
||||
Some(value) => {
|
||||
let any = strings(want)
|
||||
.iter()
|
||||
.filter_map(|p| req.resolve(p))
|
||||
.any(|p| matches(&p, &value));
|
||||
any != negated
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Allowed by some Allow and by no Deny among `stmts`.
|
||||
pub(crate) fn evaluate_in(stmts: &[Value], req: Request) -> bool {
|
||||
let applies = |s: &&Value| conditions_hold(s, &req);
|
||||
let allowed = matching(stmts, &req)
|
||||
.into_iter()
|
||||
.filter(|s| effect(s) == "Allow")
|
||||
.any(|s| applies(&s));
|
||||
let denied = matching(stmts, &req)
|
||||
.into_iter()
|
||||
.filter(|s| effect(s) == "Deny")
|
||||
.any(|s| applies(&s));
|
||||
allowed && !denied
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ContextValue::Literal;
|
||||
|
||||
#[test]
|
||||
fn glob_behaves_like_iam_wildcards() {
|
||||
assert!(glob("ecs:Describe*", "ecs:DescribeClusters"));
|
||||
assert!(!glob("ecs:Describe*", "ecs:ListClusters"));
|
||||
assert!(glob(
|
||||
"arn:aws:ecs:*:*:task/triple-c-*/*",
|
||||
"arn:aws:ecs:us-west-2:1:task/triple-c-x/abc"
|
||||
));
|
||||
assert!(!glob(
|
||||
"arn:aws:ecs:*:*:task/triple-c-*/*",
|
||||
"arn:aws:ecs:us-west-2:1:task/other/abc"
|
||||
));
|
||||
assert!(glob("a?c", "abc"));
|
||||
assert!(!glob("a?c", "ac"));
|
||||
// `${*}` (resolved to LITERAL_STAR) matches only a real `*`.
|
||||
let literal = format!("arn:aws:ec2:*:*:{LITERAL_STAR}/{LITERAL_STAR}");
|
||||
assert!(glob(&literal, "arn:aws:ec2:us-west-2:1:*/*"));
|
||||
assert!(!glob(&literal, "arn:aws:ec2:us-west-2:1:elastic-ip/eipalloc-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_all_values_is_vacuous_on_an_absent_key() {
|
||||
let stmt: Value = serde_json::json!({
|
||||
"Sid": "T", "Effect": "Allow", "Action": "x:Y", "Resource": "*",
|
||||
"Condition": { "ForAllValues:StringEquals": { "aws:TagKeys": ["a", "b"] } }
|
||||
});
|
||||
let check = |context| Check {
|
||||
action: "x:Y",
|
||||
resource: "*",
|
||||
context,
|
||||
write: true,
|
||||
};
|
||||
let holds = |c: &Check| {
|
||||
conditions_hold(
|
||||
&stmt,
|
||||
&Request {
|
||||
check: c,
|
||||
caller: ME,
|
||||
owner: ME,
|
||||
},
|
||||
)
|
||||
};
|
||||
assert!(holds(&check(&[])));
|
||||
assert!(holds(&check(&[
|
||||
("aws:TagKeys", Literal("a")),
|
||||
("aws:TagKeys", Literal("b"))
|
||||
])));
|
||||
assert!(!holds(&check(&[
|
||||
("aws:TagKeys", Literal("a")),
|
||||
("aws:TagKeys", Literal("c"))
|
||||
])));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,251 @@
|
||||
//! The OpenTofu modules Triple-C ships (`aws/terraform/*` at the repo root)
|
||||
//! and the names the modules and the app agree on.
|
||||
//!
|
||||
//! Every name a module or a phase-4 command uses — environment, parameter,
|
||||
//! owner key, per-user roles, state bucket and keys — is built here and
|
||||
//! nowhere else. The tests parse the shipped HCL and pin it to these
|
||||
//! constants: versions, default tags, the record's fields, no inbound access
|
||||
//! and no endpoint overrides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::models::host_connection::validate_supported_region;
|
||||
|
||||
/// `locals.template_version` (and the `triple-c:template-version` default
|
||||
/// tag) of `aws/terraform/environment`.
|
||||
pub const ENVIRONMENT_MODULE_VERSION: u32 = 1;
|
||||
/// The same for the user-onboarding module.
|
||||
#[allow(dead_code)] // the onboarding module's version; checked against its HCL by a test
|
||||
pub const ONBOARD_MODULE_VERSION: u32 = 1;
|
||||
/// The permissions boundary every module-created role carries (no path).
|
||||
/// Equal to [`super::policies::PERMISSIONS_BOUNDARY_NAME`]; a test checks it.
|
||||
pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModuleKind {
|
||||
Environment,
|
||||
UserOnboard,
|
||||
PermissionsBoundary,
|
||||
}
|
||||
|
||||
macro_rules! module {
|
||||
($dir:literal: $($file:literal),+ $(,)?) => {
|
||||
&[$(($file, include_str!(concat!("../../../../aws/terraform/", $dir, "/", $file)))),+]
|
||||
};
|
||||
}
|
||||
|
||||
const ENVIRONMENT_FILES: &[(&str, &str)] = module!("environment":
|
||||
"versions.tf",
|
||||
"variables.tf",
|
||||
"main.tf",
|
||||
"network.tf",
|
||||
"outputs.tf",
|
||||
".terraform.lock.hcl",
|
||||
);
|
||||
|
||||
const USER_ONBOARD_FILES: &[(&str, &str)] = module!("user-onboard":
|
||||
"versions.tf",
|
||||
"variables.tf",
|
||||
"main.tf",
|
||||
"iam.tf",
|
||||
"efs.tf",
|
||||
"outputs.tf",
|
||||
".terraform.lock.hcl",
|
||||
);
|
||||
|
||||
const PERMISSIONS_BOUNDARY_FILES: &[(&str, &str)] = module!("permissions-boundary":
|
||||
"versions.tf",
|
||||
"variables.tf",
|
||||
"main.tf",
|
||||
"outputs.tf",
|
||||
".terraform.lock.hcl",
|
||||
);
|
||||
|
||||
/// The module's files as (file name relative to the module directory,
|
||||
/// contents), lock file included, for writing into a work or bundle folder.
|
||||
pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] {
|
||||
match kind {
|
||||
ModuleKind::Environment => ENVIRONMENT_FILES,
|
||||
ModuleKind::UserOnboard => USER_ONBOARD_FILES,
|
||||
ModuleKind::PermissionsBoundary => PERMISSIONS_BOUNDARY_FILES,
|
||||
}
|
||||
}
|
||||
|
||||
/// `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--` — the same rule
|
||||
/// as the module's `environment_name` validation.
|
||||
pub fn validate_environment_name(name: &str) -> Result<String, String> {
|
||||
let b = name.as_bytes();
|
||||
let ok = !b.is_empty()
|
||||
&& b.len() <= 20
|
||||
&& b[0].is_ascii_lowercase()
|
||||
&& b.iter()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-')
|
||||
&& !name.ends_with('-')
|
||||
&& !name.contains("--");
|
||||
if ok {
|
||||
Ok(name.to_string())
|
||||
} else {
|
||||
Err(format!(
|
||||
"\"{}\" is not a valid environment name: use 1 to 20 lowercase letters, digits and \
|
||||
single hyphens, starting with a letter and not ending in a hyphen",
|
||||
name.escape_debug()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The SSM parameter holding an environment's record.
|
||||
pub fn environment_parameter_name(env: &str) -> String {
|
||||
format!("/triple-c/environments/{env}")
|
||||
}
|
||||
|
||||
/// The first 12 hex characters of `sha256("<region>:<aws:userid>")`. A
|
||||
/// `UserId` holds a `:`, which IAM role names reject, so per-user names use
|
||||
/// this instead. The region is part of it because role names are
|
||||
/// account-global while environments are regional: the same user in the same
|
||||
/// environment name in two regions gets two sets of roles.
|
||||
pub fn owner_key(region: &str, user_id: &str) -> String {
|
||||
Sha256::digest(format!("{region}:{user_id}").as_bytes())
|
||||
.iter()
|
||||
.take(6)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A user's (task role, execution role) names. At most 47 characters.
|
||||
pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) {
|
||||
(
|
||||
format!("triple-c-{env}-{owner_key}-task"),
|
||||
format!("triple-c-{env}-{owner_key}-exec"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The per-account, per-region state bucket.
|
||||
pub fn state_bucket_name(account: &str, region: &str) -> String {
|
||||
format!("triple-c-tfstate-{account}-{region}")
|
||||
}
|
||||
|
||||
/// One deployment, i.e. one state object in the bucket (D9).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Deployment<'a> {
|
||||
/// The environment module for this environment name.
|
||||
Environment(&'a str),
|
||||
/// A user's onboarding module in an environment.
|
||||
User { env: &'a str, owner_key: &'a str },
|
||||
/// The account's permissions-boundary module.
|
||||
Boundary,
|
||||
}
|
||||
|
||||
/// A deployment's state object in [`state_bucket_name`] (D9); the lock is the
|
||||
/// same key plus `.tflock`.
|
||||
pub fn state_key(d: Deployment) -> String {
|
||||
match d {
|
||||
Deployment::Environment(env) => format!("environments/{env}/terraform.tfstate"),
|
||||
Deployment::User { env, owner_key } => {
|
||||
format!("users/{env}/{owner_key}/terraform.tfstate")
|
||||
}
|
||||
Deployment::Boundary => "boundary/terraform.tfstate".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How an environment's tasks reach the network (D4).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "mode", rename_all = "kebab-case")]
|
||||
pub enum NetworkChoice {
|
||||
/// A new VPC with two public subnets; tasks get a public IP. No NAT, $0.
|
||||
Public,
|
||||
/// A new VPC with private subnets behind one NAT gateway (~$33/month).
|
||||
PrivateNat,
|
||||
/// The user's own VPC and exactly two subnets.
|
||||
Existing {
|
||||
vpc_id: String,
|
||||
subnet_ids: Vec<String>,
|
||||
assign_public_ip: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// `vpc-` / `subnet-` followed by 8 or 17 lowercase hex characters.
|
||||
fn is_aws_id(id: &str, prefix: &str) -> bool {
|
||||
id.strip_prefix(prefix).is_some_and(|hex| {
|
||||
(hex.len() == 8 || hex.len() == 17)
|
||||
&& hex.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
|
||||
})
|
||||
}
|
||||
|
||||
/// The environment module's `terraform.tfvars.json`. Applies the same rules
|
||||
/// as the module's variable validations, so a bad value fails here first.
|
||||
pub fn environment_vars(
|
||||
env: &str,
|
||||
region: &str,
|
||||
network: &NetworkChoice,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let env = validate_environment_name(env)?;
|
||||
let region = validate_supported_region(region)?;
|
||||
let (mode, vpc_id, subnet_ids, assign_public_ip) = match network {
|
||||
NetworkChoice::Public => ("public", String::new(), Vec::new(), false),
|
||||
NetworkChoice::PrivateNat => ("private-nat", String::new(), Vec::new(), false),
|
||||
NetworkChoice::Existing {
|
||||
vpc_id,
|
||||
subnet_ids,
|
||||
assign_public_ip,
|
||||
} => {
|
||||
if !is_aws_id(vpc_id, "vpc-") {
|
||||
return Err(format!("\"{}\" is not a VPC id", vpc_id.escape_debug()));
|
||||
}
|
||||
if subnet_ids.len() != 2 {
|
||||
return Err(format!(
|
||||
"An existing network needs exactly 2 subnets (in different availability \
|
||||
zones), not {}",
|
||||
subnet_ids.len()
|
||||
));
|
||||
}
|
||||
if let Some(bad) = subnet_ids.iter().find(|s| !is_aws_id(s, "subnet-")) {
|
||||
return Err(format!("\"{}\" is not a subnet id", bad.escape_debug()));
|
||||
}
|
||||
if subnet_ids[0] == subnet_ids[1] {
|
||||
return Err("The two subnets must be different".into());
|
||||
}
|
||||
(
|
||||
"existing",
|
||||
vpc_id.clone(),
|
||||
subnet_ids.clone(),
|
||||
*assign_public_ip,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(json!({
|
||||
"environment_name": env,
|
||||
"region": region,
|
||||
"network_mode": mode,
|
||||
"vpc_id": vpc_id,
|
||||
"subnet_ids": subnet_ids,
|
||||
"assign_public_ip": assign_public_ip,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The onboarding module's `terraform.tfvars.json` for the user whose
|
||||
/// `aws:userid` is `owner_id`. Bedrock stays on the workspace user's own
|
||||
/// credentials unless a later setting opts the task role in.
|
||||
pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"environment_name": env,
|
||||
"region": region,
|
||||
"owner_id": owner_id,
|
||||
"owner_key": owner_key(region, owner_id),
|
||||
"bedrock_on_task_role": false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The boundary module's `terraform.tfvars.json`: the shipped boundary
|
||||
/// document itself, so the module holds no second copy.
|
||||
pub fn boundary_vars(region: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"region": region,
|
||||
"policy_json": super::policies::permissions_boundary_json(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ use tauri::{AppHandle, State};
|
||||
|
||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the pane on or off for a project.
|
||||
@@ -57,12 +58,12 @@ pub async fn set_browser_view_enabled(
|
||||
return Ok(manager().status(&project_id, false).await);
|
||||
}
|
||||
|
||||
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
let container = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
|
||||
manager()
|
||||
.start(
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -106,8 +107,8 @@ pub async fn check_browser_view_support(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||
crate::browser_view::detect::detect(&container_id).await
|
||||
let container = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||
crate::browser_view::detect::detect(&container).await
|
||||
}
|
||||
|
||||
/// Install `playwright` and `@playwright/cli` into the container.
|
||||
@@ -125,8 +126,8 @@ pub async fn install_browser_view_support(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let container_id = running_container(&state, &project_id, "installing Playwright").await?;
|
||||
install::install_packages(&app_handle, &project_id, &container_id).await
|
||||
let container = running_container(&state, &project_id, "installing Playwright").await?;
|
||||
install::install_packages(&app_handle, &project_id, &container).await
|
||||
}
|
||||
|
||||
/// Install a browser — `chromium` (Playwright's own build, for scripts that
|
||||
@@ -143,8 +144,8 @@ pub async fn install_browser_view_browser(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let target = install::BrowserTarget::parse(&browser)?;
|
||||
let container_id = running_container(&state, &project_id, "installing a browser").await?;
|
||||
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
||||
let container = running_container(&state, &project_id, "installing a browser").await?;
|
||||
install::install_browser(&app_handle, &project_id, &container, target).await
|
||||
}
|
||||
|
||||
/// Detach the view into a window of its own, or raise the one already open.
|
||||
@@ -240,17 +241,17 @@ pub async fn open_page_in_container_browser(
|
||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||
return Err("Only http:// and https:// URLs can be opened in the browser.".to_string());
|
||||
}
|
||||
let container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||
let container = running_container(&state, &project_id, "opening a page").await?;
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"Checking the container for Playwright…",
|
||||
);
|
||||
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||
let detection = crate::browser_view::detect::detect(&container).await?;
|
||||
let opened = page::open(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
&container_id,
|
||||
&container,
|
||||
&detection,
|
||||
trimmed,
|
||||
page::Viewport::sane(width, height),
|
||||
@@ -273,7 +274,7 @@ pub async fn open_page_in_container_browser(
|
||||
manager()
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -309,8 +310,8 @@ pub async fn set_container_page_viewport(
|
||||
height: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
let container = running_container(&state, &project_id, "resizing the page").await?;
|
||||
page::set_viewport(&container, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||
@@ -319,10 +320,10 @@ pub async fn get_container_page_state(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<page::PageState, String> {
|
||||
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||
let Ok(container) = running_container(&state, &project_id, "reading the page").await else {
|
||||
return Ok(page::PageState::default());
|
||||
};
|
||||
Ok(page::state(&container_id).await)
|
||||
Ok(page::state(&container).await)
|
||||
}
|
||||
|
||||
/// Close the page this opened, leaving the view itself running.
|
||||
@@ -331,8 +332,8 @@ pub async fn close_container_page(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||
page::close(&container_id).await;
|
||||
let container = running_container(&state, &project_id, "closing the page").await?;
|
||||
page::close(&container).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -356,8 +357,8 @@ pub async fn set_browser_view_match_window(
|
||||
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
let container = running_container(&state, &project_id, "matching the window").await?;
|
||||
page::set_viewport(&container, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||
@@ -391,28 +392,25 @@ async fn running_container(
|
||||
state: &State<'_, AppState>,
|
||||
project_id: &str,
|
||||
action: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let Some(container_id) = project.container_id.clone() else {
|
||||
let Some(container) = ContainerTarget::for_project(&project) else {
|
||||
return Err(format!(
|
||||
"This project has no container yet. Start it before {}.",
|
||||
action
|
||||
));
|
||||
};
|
||||
if !crate::docker::container::is_container_running(&container_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"The container for “{}” isn't running. Start it before {}.",
|
||||
project.name, action
|
||||
));
|
||||
}
|
||||
Ok(container_id)
|
||||
Ok(container)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
|
||||
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
|
||||
@@ -277,12 +277,14 @@ impl PlaywrightDetection {
|
||||
/// Playwright installed with `npm i -g`, or merely *run* once through
|
||||
/// `npx @playwright/mcp`, is found as readily as one in
|
||||
/// `/workspace/node_modules`.
|
||||
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
||||
let output = exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
|
||||
)
|
||||
.await?;
|
||||
pub async fn detect(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
|
||||
let output = container
|
||||
.exec_oneshot(vec![
|
||||
"node".to_string(),
|
||||
"-e".to_string(),
|
||||
PROBE.to_string(),
|
||||
])
|
||||
.await?;
|
||||
|
||||
parse_probe_output(&output)
|
||||
}
|
||||
|
||||
@@ -67,9 +67,7 @@ use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::{
|
||||
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
|
||||
};
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecSpec};
|
||||
|
||||
use super::detect::{self, PlaywrightDetection};
|
||||
|
||||
@@ -240,7 +238,7 @@ pub struct BrowserSetupOutcome {
|
||||
pub async fn install_packages(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
emit_progress(
|
||||
app,
|
||||
@@ -248,7 +246,7 @@ pub async fn install_packages(
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
|
||||
let mut step = npm_install(app, project_id, container, &[VIEWER_PACKAGE]).await?;
|
||||
if step.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -265,9 +263,9 @@ pub async fn install_packages(
|
||||
// see the note on `npm_install`. The pin can only be read after the first
|
||||
// install has written the manifest, which is why this stays two commands
|
||||
// rather than one.
|
||||
let spec = pinned_playwright_spec(container_id).await;
|
||||
let spec = pinned_playwright_spec(container).await;
|
||||
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||
let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
let second = npm_install(app, project_id, container, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
if second.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -279,7 +277,7 @@ pub async fn install_packages(
|
||||
step.log = merge_logs(step.log, second.log);
|
||||
|
||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||
let detection = detect::detect(container_id).await?;
|
||||
let detection = detect::detect(container).await?;
|
||||
|
||||
// A probe that still finds something missing after a successful npm run is
|
||||
// the interesting case, so it is surfaced rather than swallowed. And a
|
||||
@@ -331,7 +329,7 @@ pub async fn install_packages(
|
||||
async fn npm_install(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
specs: &[&str],
|
||||
) -> Result<StepResult, String> {
|
||||
let mut cmd = vec![
|
||||
@@ -348,7 +346,7 @@ async fn npm_install(
|
||||
run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
"claude",
|
||||
INSTALL_DIR,
|
||||
cmd,
|
||||
@@ -363,20 +361,20 @@ async fn npm_install(
|
||||
/// Read from the manifest npm just wrote rather than guessed, and falling back
|
||||
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
|
||||
/// install something, not nothing.
|
||||
async fn pinned_playwright_spec(container_id: &str) -> String {
|
||||
async fn pinned_playwright_spec(container: &ContainerTarget) -> String {
|
||||
let script = format!(
|
||||
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
|
||||
process.stdout.write(d.playwright||'');}}catch(e){{}}",
|
||||
INSTALL_DIR
|
||||
);
|
||||
let (out, _code) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (out, _code) = container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let version: &str = out.trim();
|
||||
// A version, not a range or a URL: anything else goes to the fallback
|
||||
@@ -411,10 +409,10 @@ fn merge_logs(first: String, second: String) -> String {
|
||||
pub async fn install_browser(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
target: BrowserTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let before = detect::detect(container_id).await?;
|
||||
let before = detect::detect(container).await?;
|
||||
let Some(cli) = before.playwright_cli.clone() else {
|
||||
return Err(
|
||||
"Playwright isn't installed in this container yet, so there is nothing to install a \
|
||||
@@ -458,7 +456,7 @@ pub async fn install_browser(
|
||||
project_id,
|
||||
"Step 1/3 — checking whether this container already has the browser system libraries…",
|
||||
);
|
||||
let state = check_libraries(container_id, &cli, target).await;
|
||||
let state = check_libraries(container, &cli, target).await;
|
||||
match &state {
|
||||
LibraryState::Present => {
|
||||
emit_progress(
|
||||
@@ -482,7 +480,7 @@ pub async fn install_browser(
|
||||
let deps = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
"root",
|
||||
"/tmp",
|
||||
vec![
|
||||
@@ -530,7 +528,7 @@ pub async fn install_browser(
|
||||
let dl = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
user,
|
||||
workdir,
|
||||
vec![
|
||||
@@ -559,7 +557,7 @@ pub async fn install_browser(
|
||||
project_id,
|
||||
&format!("Step 3/3 — checking that {} actually launches…", target.cli_name()),
|
||||
);
|
||||
let verdict = verify_launch(container_id, &before, target).await;
|
||||
let verdict = verify_launch(container, &before, target).await;
|
||||
push_section(&mut log, &verdict.detail);
|
||||
if !verdict.ok {
|
||||
warning = merge(
|
||||
@@ -590,7 +588,7 @@ pub async fn install_browser(
|
||||
}
|
||||
|
||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||
let detection = detect::detect(container_id).await?;
|
||||
let detection = detect::detect(container).await?;
|
||||
|
||||
Ok(BrowserSetupOutcome {
|
||||
detection,
|
||||
@@ -642,9 +640,12 @@ impl LibraryState {
|
||||
/// dpkg's status file. A *missing* package on such an image is simply not in
|
||||
/// any index, so apt fails, Playwright reports the failure, and this returns
|
||||
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
|
||||
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
|
||||
let run = exec_oneshot_as(
|
||||
container_id,
|
||||
async fn check_libraries(
|
||||
container: &ContainerTarget,
|
||||
cli: &str,
|
||||
target: BrowserTarget,
|
||||
) -> LibraryState {
|
||||
let run = container.exec_oneshot_as(
|
||||
"root",
|
||||
vec![
|
||||
"node".to_string(),
|
||||
@@ -708,7 +709,7 @@ struct LaunchVerdict {
|
||||
/// Launch the browser headless, load one page, and close it. Seconds, and it is
|
||||
/// the only thing that distinguishes "downloaded" from "usable".
|
||||
async fn verify_launch(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
detection: &PlaywrightDetection,
|
||||
target: BrowserTarget,
|
||||
) -> LaunchVerdict {
|
||||
@@ -724,8 +725,7 @@ async fn verify_launch(
|
||||
};
|
||||
let dir = manifest.trim_end_matches("package.json").trim_end_matches('/');
|
||||
|
||||
let run = exec_oneshot_as(
|
||||
container_id,
|
||||
let run = container.exec_oneshot_as(
|
||||
"claude",
|
||||
vec![
|
||||
"node".to_string(),
|
||||
@@ -884,13 +884,13 @@ impl StepResult {
|
||||
/// Run one command in the container, streaming every line it prints to the pane
|
||||
/// as `container-progress` and keeping the tail for the result.
|
||||
///
|
||||
/// Uses `create_attached_exec_as` — the single attached-exec path — rather than
|
||||
/// Uses [`ContainerTarget::attach`] — the single attached-exec path — rather than
|
||||
/// `exec_oneshot`, because these commands run for minutes and the point is that
|
||||
/// the user can watch them.
|
||||
async fn run_step(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
user: &str,
|
||||
workdir: &str,
|
||||
cmd: Vec<String>,
|
||||
@@ -900,7 +900,14 @@ async fn run_step(
|
||||
exec_id,
|
||||
mut output,
|
||||
input,
|
||||
} = create_attached_exec_as(container_id, cmd, false, user, workdir).await?;
|
||||
} = container
|
||||
.attach(
|
||||
ExecSpec::new(cmd)
|
||||
.tty(false)
|
||||
.user(user)
|
||||
.working_dir(Some(workdir)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Nothing is ever written to this exec. Closing stdin means anything that
|
||||
// would prompt sees EOF and gives up, instead of waiting for a person who
|
||||
@@ -944,7 +951,7 @@ async fn run_step(
|
||||
)),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Ok(Ok(())) => Ok(StepResult {
|
||||
exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0),
|
||||
exit_code: container.wait_exec_exit(&exec_id).await.unwrap_or(0),
|
||||
log,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_running` check |
|
||||
//! | Project deleted | supervisor's `store.get()` check |
|
||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
||||
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
|
||||
@@ -90,8 +90,7 @@ use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::auth_bridge::proc_net::{self, PortFamily};
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use detect::PlaywrightDetection;
|
||||
@@ -282,7 +281,7 @@ impl BrowserViewManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
@@ -300,7 +299,7 @@ impl BrowserViewManager {
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).await?;
|
||||
let detection = detect::detect(&container).await?;
|
||||
if !detection.is_usable() {
|
||||
let blocker = detection.blocker().unwrap_or_else(|| {
|
||||
"Playwright is present but incomplete in this container.".to_string()
|
||||
@@ -320,15 +319,15 @@ impl BrowserViewManager {
|
||||
// Doing this before choosing a port also frees the one a previous
|
||||
// session was using, so sessions don't walk up the range. Best-effort:
|
||||
// a container with no dashboard makes this a no-op.
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
|
||||
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||
let (container_port, entry_path) = start_viewer(&container, &cli_entry).await?;
|
||||
|
||||
let token = generate_token();
|
||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||
// no need to go back to /proc/net to work it out.
|
||||
let proxy = match BrowserViewProxy::bind(
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
container_port,
|
||||
PortFamily::V4,
|
||||
token.clone(),
|
||||
@@ -337,7 +336,7 @@ impl BrowserViewManager {
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -354,7 +353,7 @@ impl BrowserViewManager {
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let supervisor = tokio::spawn(supervise(
|
||||
project_id.clone(),
|
||||
container_id.clone(),
|
||||
container,
|
||||
cli_entry,
|
||||
container_port,
|
||||
epoch,
|
||||
@@ -427,7 +426,7 @@ impl BrowserViewManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn supervise(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
cli_entry: String,
|
||||
container_port: u16,
|
||||
epoch: u64,
|
||||
@@ -443,7 +442,7 @@ async fn supervise(
|
||||
log::info!("Browser view: project {} is gone — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Browser view: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
@@ -459,7 +458,7 @@ async fn supervise(
|
||||
// view never waits out an in-flight exec.
|
||||
let alive = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
|
||||
res = probe_entry_path(&container, container_port) => res.is_ok(),
|
||||
};
|
||||
if !alive {
|
||||
log::warn!(
|
||||
@@ -477,7 +476,7 @@ async fn supervise(
|
||||
}
|
||||
|
||||
proxy.shutdown().await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
|
||||
// Deregister, unless a newer session has already taken this project's slot.
|
||||
let superseded = {
|
||||
@@ -538,7 +537,11 @@ const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
|
||||
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
|
||||
/// rather than waiting on an inherited stdout, and so a failure has a trail),
|
||||
/// and readiness is established by [`wait_until_ready`] instead.
|
||||
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
|
||||
async fn launch_viewer(
|
||||
container: &ContainerTarget,
|
||||
cli_entry: &str,
|
||||
port: u16,
|
||||
) -> Result<(), String> {
|
||||
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
|
||||
// launch; the container may have no egress, and we don't want to wait out a
|
||||
// DNS timeout before the dashboard binds.
|
||||
@@ -549,13 +552,11 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
|
||||
port,
|
||||
VIEWER_LOG
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||
container
|
||||
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||
}
|
||||
|
||||
/// The dashboard singleton is keyed on a hash of the working directory, so
|
||||
@@ -567,17 +568,15 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
|
||||
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
|
||||
|
||||
/// Stop the dashboard daemon. Verified to free the port and stop answering.
|
||||
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
|
||||
async fn kill_dashboard(container: &ContainerTarget, cli_entry: &str) -> Result<String, String> {
|
||||
let script = format!(
|
||||
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
|
||||
WORKDIR_PREFIX,
|
||||
shell_quote(cli_entry)
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
container
|
||||
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Turn a failed start into something the user can act on.
|
||||
@@ -604,13 +603,16 @@ fn explain_start_failure(err: &str, log: &str) -> String {
|
||||
}
|
||||
|
||||
/// Tail of the viewer's own output, for a start that didn't come up.
|
||||
async fn read_viewer_log(container_id: &str) -> String {
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
async fn read_viewer_log(container: &ContainerTarget) -> String {
|
||||
container
|
||||
.exec_oneshot(vec![
|
||||
"tail".to_string(),
|
||||
"-n".to_string(),
|
||||
"40".to_string(),
|
||||
VIEWER_LOG.to_string(),
|
||||
])
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -645,14 +647,17 @@ const MAX_PORT_ATTEMPTS: usize = 3;
|
||||
/// this layer, and the pane would then front it. What contains that is
|
||||
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||
/// iframe is sandboxed — not this function.
|
||||
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||
async fn start_viewer(
|
||||
container: &ContainerTarget,
|
||||
cli_entry: &str,
|
||||
) -> Result<(u16, String), String> {
|
||||
let mut tried: Vec<u16> = Vec::new();
|
||||
let mut last: Option<String> = None;
|
||||
|
||||
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||
// Re-read the listener snapshot each attempt: the port that was free a
|
||||
// moment ago is exactly the one we may have just lost.
|
||||
let port = match pick_viewer_port(container_id, &tried).await {
|
||||
let port = match pick_viewer_port(container, &tried).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report why the *attempts* failed, not just "nothing free":
|
||||
@@ -666,21 +671,21 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
|
||||
};
|
||||
tried.push(port);
|
||||
|
||||
launch_viewer(container_id, cli_entry, port).await?;
|
||||
launch_viewer(container, cli_entry, port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
match wait_until_ready(container_id, port).await {
|
||||
match wait_until_ready(container, port).await {
|
||||
Ok(path) => return Ok((port, path)),
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(container_id).await;
|
||||
let log = read_viewer_log(container).await;
|
||||
// Always kill before retrying: the dashboard is a singleton, so
|
||||
// a launcher that came up on some *other* port would otherwise
|
||||
// make every further attempt a no-op that silently ignores the
|
||||
// port we asked for.
|
||||
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||
let _ = kill_dashboard(container, cli_entry).await;
|
||||
last = Some(explain_start_failure(&e, &log));
|
||||
}
|
||||
}
|
||||
@@ -691,10 +696,9 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||
/// and that this start has not already tried.
|
||||
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = exec_oneshot(
|
||||
container_id,
|
||||
vec![
|
||||
async fn pick_viewer_port(container: &ContainerTarget, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = container
|
||||
.exec_oneshot(vec![
|
||||
// Absolute path, deliberately, for the same reason the auth bridge
|
||||
// uses one: `container/Dockerfile` puts a container-writable
|
||||
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||
@@ -704,10 +708,9 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
|
||||
"/usr/bin/cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let taken = proc_net::parse_loopback_listeners(&text);
|
||||
VIEWER_PORTS
|
||||
.clone()
|
||||
@@ -722,10 +725,10 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
|
||||
}
|
||||
|
||||
/// Poll the viewer until it answers, and return the path the pane should load.
|
||||
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> {
|
||||
async fn wait_until_ready(container: &ContainerTarget, port: u16) -> Result<String, String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let last = match probe_entry_path(container_id, port).await {
|
||||
let last = match probe_entry_path(container, port).await {
|
||||
Ok(path) => return Ok(path),
|
||||
Err(e) => e,
|
||||
};
|
||||
@@ -751,7 +754,7 @@ const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
|
||||
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
|
||||
/// gate wants and would leave a fresh connection to be authorised with nothing.
|
||||
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
|
||||
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> {
|
||||
async fn probe_entry_path(container: &ContainerTarget, port: u16) -> Result<String, String> {
|
||||
// The request is bounded on both sides. Verified: the dashboard answers a
|
||||
// bad WebSocket path by holding the socket open forever rather than
|
||||
// erroring, so "no reply" is a state this probe has to be able to leave —
|
||||
@@ -766,10 +769,7 @@ async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, Strin
|
||||
);
|
||||
let out = tokio::time::timeout(
|
||||
PROBE_TIMEOUT * 2,
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
),
|
||||
container.exec_oneshot(vec!["node".to_string(), "-e".to_string(), script]),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "the viewer probe did not return".to_string())??;
|
||||
|
||||
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::exec_oneshot_as;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
use super::detect::PlaywrightDetection;
|
||||
|
||||
@@ -91,7 +91,7 @@ pub struct PageState {
|
||||
pub async fn open(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
detection: &PlaywrightDetection,
|
||||
url: &str,
|
||||
viewport: Viewport,
|
||||
@@ -115,17 +115,17 @@ pub async fn open(
|
||||
// Reuse a helper that is already up. Relaunching would throw away the
|
||||
// browser's cookies and storage — which for the auth case means signing in
|
||||
// again to reach the second page, having just signed in on the first.
|
||||
if state(container_id).await.ready {
|
||||
if state(container).await.ready {
|
||||
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||
set_viewport(container_id, viewport).await?;
|
||||
navigate(container_id, url).await?;
|
||||
if let Some(state) = wait_for_url(container_id, url).await {
|
||||
set_viewport(container, viewport).await?;
|
||||
navigate(container, url).await?;
|
||||
if let Some(state) = wait_for_url(container, url).await {
|
||||
return Ok(state);
|
||||
}
|
||||
// It stopped answering; fall through and start a fresh one.
|
||||
}
|
||||
|
||||
close(container_id).await;
|
||||
close(container).await;
|
||||
// Cold start: a browser launch plus a page load, which is the several
|
||||
// seconds the user would otherwise spend wondering whether the click
|
||||
// registered.
|
||||
@@ -150,52 +150,52 @@ pub async fn open(
|
||||
shell_quote(&script),
|
||||
HELPER_LOG
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
|
||||
emit_progress(app, project_id, "Waiting for the page to load…");
|
||||
wait_until_ready(container_id).await
|
||||
wait_until_ready(container).await
|
||||
}
|
||||
|
||||
/// Resize the open page. Cheap enough to call from a window-resize handler.
|
||||
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
|
||||
pub async fn set_viewport(container: &ContainerTarget, viewport: Viewport) -> Result<(), String> {
|
||||
write_control(
|
||||
container_id,
|
||||
container,
|
||||
serde_json::json!({ "viewport": viewport }).to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Navigate the open page without relaunching the browser.
|
||||
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
|
||||
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
|
||||
pub async fn navigate(container: &ContainerTarget, url: &str) -> Result<(), String> {
|
||||
write_control(container, serde_json::json!({ "url": url }).to_string()).await
|
||||
}
|
||||
|
||||
/// Ask the helper to shut down. Best effort: a container that has none is the
|
||||
/// normal case, and the caller is usually about to start one anyway.
|
||||
pub async fn close(container_id: &str) {
|
||||
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
|
||||
pub async fn close(container: &ContainerTarget) {
|
||||
let _ = write_control(container, serde_json::json!({ "close": true }).to_string()).await;
|
||||
}
|
||||
|
||||
/// Current state, or a default when no helper has ever run here.
|
||||
pub async fn state(container_id: &str) -> PageState {
|
||||
pub async fn state(container: &ContainerTarget) -> PageState {
|
||||
let script = format!(
|
||||
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
|
||||
STATE_PATH
|
||||
);
|
||||
let Ok((out, _)) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
let Ok((out, _)) = container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return PageState::default();
|
||||
};
|
||||
@@ -204,20 +204,20 @@ pub async fn state(container_id: &str) -> PageState {
|
||||
|
||||
/// Write the control file through Node rather than a shell redirect, so a URL
|
||||
/// is never interpreted by `sh`.
|
||||
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
async fn write_control(container: &ContainerTarget, json: String) -> Result<(), String> {
|
||||
let script = format!(
|
||||
"require('fs').writeFileSync('{}',process.argv[1]);",
|
||||
CONTROL_PATH
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||
container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||
}
|
||||
|
||||
/// Wait for a *running* helper to report the URL we just asked it for.
|
||||
@@ -225,10 +225,10 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
/// Bounded much tighter than a cold start: the browser is already up, so this
|
||||
/// is one navigation. `None` means it stopped answering, and the caller starts
|
||||
/// a fresh helper rather than reporting a page that isn't there.
|
||||
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||
async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option<PageState> {
|
||||
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
let state = state(container).await;
|
||||
if state.ready && state.url.as_deref() == Some(url) {
|
||||
return Some(state);
|
||||
}
|
||||
@@ -240,10 +240,10 @@ async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||
}
|
||||
|
||||
/// Poll the state file until the helper says the page is up, or says why not.
|
||||
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
|
||||
async fn wait_until_ready(container: &ContainerTarget) -> Result<PageState, String> {
|
||||
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
let state = state(container).await;
|
||||
if let Some(error) = state.error.clone() {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
@@ -255,15 +255,15 @@ fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||
}
|
||||
|
||||
let state = app.state::<crate::AppState>();
|
||||
let Some(container_id) = state
|
||||
let Some(container) = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.and_then(|p| p.container_id)
|
||||
.and_then(|p| crate::host::ContainerTarget::for_project(&p))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = super::page::set_viewport(
|
||||
&container_id,
|
||||
&container,
|
||||
super::page::Viewport::sane(width, height),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -62,6 +62,7 @@ use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
use crate::auth_bridge::proc_net::PortFamily;
|
||||
use crate::auth_bridge::tunnel::tunnel_connection_with_prelude;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
/// Host loopback ports the pane may be served on, and therefore the exact set of
|
||||
/// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a
|
||||
@@ -103,9 +104,9 @@ impl Drop for BrowserViewProxy {
|
||||
|
||||
impl BrowserViewProxy {
|
||||
/// Take the first free port in [`PROXY_PORTS`] on the host loopback and
|
||||
/// start gating connections into `container_id`'s `container_port`.
|
||||
/// start gating connections into `container`'s `container_port`.
|
||||
pub async fn bind(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
container_port: u16,
|
||||
family: PortFamily,
|
||||
token: String,
|
||||
@@ -121,7 +122,7 @@ impl BrowserViewProxy {
|
||||
Ok(listener) => {
|
||||
let task = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
container_id,
|
||||
container,
|
||||
family.socat_target(container_port),
|
||||
container_port,
|
||||
token,
|
||||
@@ -172,7 +173,7 @@ fn host_authorities(port: u16) -> Vec<String> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
@@ -195,7 +196,7 @@ async fn accept_loop(
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(serve_connection(
|
||||
stream,
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
target.clone(),
|
||||
container_port,
|
||||
token.clone(),
|
||||
@@ -214,7 +215,7 @@ async fn accept_loop(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn serve_connection(
|
||||
mut stream: TcpStream,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
@@ -249,7 +250,7 @@ async fn serve_connection(
|
||||
|
||||
// Authorized: hand the socket to the same socat-over-Docker-exec tunnel the
|
||||
// auth bridge uses, replaying the head we had to buffer to make the call.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await;
|
||||
tunnel_connection_with_prelude(container, target, stream, container_port, head).await;
|
||||
}
|
||||
|
||||
/// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::auth_bridge::AuthBridgeStatus;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the bridge on or off for a project and return the resulting status.
|
||||
@@ -29,16 +30,13 @@ pub async fn set_auth_bridge_enabled(
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
if let Some(container_id) = project.container_id {
|
||||
if crate::docker::container::is_container_running(&container_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
if container.is_running().await.unwrap_or(false) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
|
||||
@@ -76,8 +76,7 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
|
||||
use crate::host::{AttachedExec, ContainerTarget};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -867,7 +866,7 @@ async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
|
||||
async fn run_setup_token(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
mut cancel_rx: oneshot::Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
@@ -877,16 +876,16 @@ async fn run_setup_token(
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = create_attached_exec(
|
||||
container_id,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
SETUP_TOKEN_SCRIPT.to_string(),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
} = target
|
||||
.attach_exec(
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
SETUP_TOKEN_SCRIPT.to_string(),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut stripper = AnsiStripper::default();
|
||||
let mut redactor = SecretRedactor::default();
|
||||
@@ -1041,7 +1040,7 @@ async fn run_setup_token(
|
||||
// Falling through to the token parse is right either way — a run that
|
||||
// printed a token succeeded whatever Docker says about it — but it is worth
|
||||
// saying so rather than silently calling it a clean exit.
|
||||
match wait_for_exec_exit(&exec_id).await {
|
||||
match target.wait_exec_exit(&exec_id).await {
|
||||
Some(0) => {}
|
||||
Some(code) => {
|
||||
return Err(format!(
|
||||
@@ -1091,13 +1090,13 @@ pub async fn acquire_claude_token(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||
let target = ContainerTarget::for_project(&project).ok_or_else(|| {
|
||||
format!(
|
||||
"Project '{}' has no container yet. Start it, then run authentication again.",
|
||||
project.name
|
||||
)
|
||||
})?;
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !target.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"The container for '{}' is not running. Start it, then run authentication again.",
|
||||
project.name
|
||||
@@ -1133,8 +1132,7 @@ pub async fn acquire_claude_token(
|
||||
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
|
||||
);
|
||||
|
||||
let result =
|
||||
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await;
|
||||
let result = run_setup_token(&app_handle, &project_id, &target, input_rx, cancel_rx).await;
|
||||
|
||||
// Release the flow. Nothing else needs unwinding: this command changes no
|
||||
// persisted state until the token itself is stored, which is the point.
|
||||
|
||||
@@ -52,7 +52,8 @@ pub async fn run_sso_login(profile: &str) -> Result<(), String> {
|
||||
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
||||
|
||||
let status = tokio::process::Command::new("aws")
|
||||
.args(["sso", "login", "--profile", profile])
|
||||
// One argv element, so a name starting with `-` is never an option.
|
||||
.args(["sso", "login", &format!("--profile={profile}")])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,631 @@
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::aws::tofu::Workdir;
|
||||
use crate::aws::environments::{
|
||||
EnvironmentState, Knowable, LockInfo, OnboardingState,
|
||||
};
|
||||
use crate::aws::tofu_modules::{
|
||||
environment_vars, module_files, onboarding_vars, owner_key, state_key, ModuleKind,
|
||||
};
|
||||
|
||||
const ACCOUNT: &str = "123456789012";
|
||||
const REGION: &str = "eu-west-1";
|
||||
const USER_ID: &str = "AROAEXAMPLEID12345678:alice";
|
||||
|
||||
fn info(name: &str, mode: &str, assign_public_ip: bool) -> EnvironmentInfo {
|
||||
EnvironmentInfo {
|
||||
schema: 1,
|
||||
name: name.into(),
|
||||
template_version: 1,
|
||||
cluster_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-{name}"),
|
||||
security_group_id: "sg-0123456789abcdef0".into(),
|
||||
subnet_ids: vec![
|
||||
"subnet-0123456789abcdef0".into(),
|
||||
"subnet-0fedcba9876543210".into(),
|
||||
],
|
||||
assign_public_ip,
|
||||
kms_key_arn: format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234abcd-12ab"),
|
||||
log_group: format!("/triple-c/{name}"),
|
||||
network_mode: mode.into(),
|
||||
tenancy: "strict".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn env(name: &str, state: EnvironmentState, actions: Vec<EnvironmentAction>) -> DetectedEnvironment {
|
||||
DetectedEnvironment {
|
||||
name: name.into(),
|
||||
template_version: None,
|
||||
state,
|
||||
actions,
|
||||
info: None,
|
||||
onboarding: None,
|
||||
onboarding_actions: Vec::new(),
|
||||
onboarded_users: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(boundary: Knowable, environments: Vec<DetectedEnvironment>) -> EnvironmentScan {
|
||||
EnvironmentScan {
|
||||
region: REGION.into(),
|
||||
account: ACCOUNT.into(),
|
||||
owner_id: USER_ID.into(),
|
||||
owner_key: owner_key(REGION, USER_ID),
|
||||
state_bucket: Knowable::Yes,
|
||||
boundary,
|
||||
environments,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(age_secs: u64, local_process: bool) -> LockInfo {
|
||||
LockInfo {
|
||||
who: "someone@host".into(),
|
||||
created: String::new(),
|
||||
operation: "OperationTypeApply".into(),
|
||||
age_secs,
|
||||
local_process,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_our_state_keys_are_accepted() {
|
||||
let ok = owner_key(REGION, USER_ID);
|
||||
let good = [
|
||||
(
|
||||
"environments/dev/terraform.tfstate".to_string(),
|
||||
DeploymentKey::Environment("dev".into()),
|
||||
),
|
||||
(
|
||||
format!("users/dev/{ok}/terraform.tfstate"),
|
||||
DeploymentKey::User { env: "dev".into(), owner_key: ok.clone() },
|
||||
),
|
||||
("boundary/terraform.tfstate".to_string(), DeploymentKey::Boundary),
|
||||
];
|
||||
for (key, want) in good {
|
||||
let parsed = parse_state_key(&key).unwrap();
|
||||
assert_eq!(parsed, want);
|
||||
assert_eq!(state_key(parsed.deployment()), key, "round trip");
|
||||
}
|
||||
let bad = [
|
||||
"",
|
||||
"environments/dev/terraform.tfstate.tflock",
|
||||
"environments/Dev/terraform.tfstate",
|
||||
"environments/dev-/terraform.tfstate",
|
||||
"environments/../terraform.tfstate",
|
||||
"environments/dev/../../boundary/terraform.tfstate",
|
||||
"/environments/dev/terraform.tfstate",
|
||||
"environments//terraform.tfstate",
|
||||
"environments/dev/x/terraform.tfstate",
|
||||
"environments/dev/terraform.tfstate/",
|
||||
"users/dev/terraform.tfstate",
|
||||
"users/dev/ABCDEF012345/terraform.tfstate",
|
||||
"users/dev/abcdef01234/terraform.tfstate",
|
||||
"users/dev/abcdef0123456/terraform.tfstate",
|
||||
"users/dev/abcdef01234g/terraform.tfstate",
|
||||
"boundary/x/terraform.tfstate",
|
||||
"boundary/terraform.tfstate ",
|
||||
"something/else",
|
||||
"environments\\dev\\terraform.tfstate",
|
||||
];
|
||||
for key in bad {
|
||||
assert!(parse_state_key(key).is_err(), "{key:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deploy_refuses_an_existing_name_and_a_missing_boundary() {
|
||||
use EnvironmentAction::*;
|
||||
let existing = vec![
|
||||
env("dev", EnvironmentState::Ready, vec![Join]),
|
||||
env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
|
||||
];
|
||||
let s = scan(Knowable::Yes, existing.clone());
|
||||
assert!(deploy_refusal(&s, "prod").is_ok());
|
||||
let e = deploy_refusal(&s, "dev").unwrap_err();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
assert!(deploy_refusal(&s, "half").is_err(), "state without a record is taken too");
|
||||
assert!(deploy_refusal(&s, "Not Valid").is_err());
|
||||
|
||||
let no_boundary = scan(Knowable::No, existing.clone());
|
||||
let e = deploy_refusal(&no_boundary, "prod").unwrap_err();
|
||||
assert!(e.contains("permissions boundary"), "{e}");
|
||||
// A caller who can't tell is let through (the apply itself would fail).
|
||||
assert!(deploy_refusal(&scan(Knowable::Unknown, existing), "prod").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_reuses_the_recorded_network() {
|
||||
assert_eq!(
|
||||
network_from_record(&info("dev", "public", true), None).unwrap(),
|
||||
NetworkChoice::Public
|
||||
);
|
||||
assert_eq!(
|
||||
network_from_record(&info("dev", "private-nat", false), None).unwrap(),
|
||||
NetworkChoice::PrivateNat
|
||||
);
|
||||
let existing = info("dev", "existing", false);
|
||||
assert_eq!(
|
||||
network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(),
|
||||
NetworkChoice::Existing {
|
||||
vpc_id: "vpc-0123456789abcdef0".into(),
|
||||
subnet_ids: existing.subnet_ids.clone(),
|
||||
assign_public_ip: false,
|
||||
}
|
||||
);
|
||||
// The VPC isn't in the record: without it from the state, refuse rather
|
||||
// than guess.
|
||||
assert!(network_from_record(&existing, None).is_err());
|
||||
assert!(network_from_record(&existing, Some("not-a-vpc")).is_err());
|
||||
assert!(network_from_record(&info("dev", "carrier-pigeon", false), None).is_err());
|
||||
// And it round-trips into the module's variables unchanged.
|
||||
let vars = environment_vars(
|
||||
"dev",
|
||||
REGION,
|
||||
&network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(vars["network_mode"], "existing");
|
||||
assert_eq!(vars["subnet_ids"], json!(existing.subnet_ids));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vpc_is_read_from_the_state_outputs() {
|
||||
let state = json!({
|
||||
"version": 4,
|
||||
"outputs": { "vpc_id": { "value": "vpc-0123456789abcdef0", "type": "string" } },
|
||||
})
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
vpc_id_from_state(state.as_bytes()).as_deref(),
|
||||
Some("vpc-0123456789abcdef0")
|
||||
);
|
||||
assert_eq!(vpc_id_from_state(b"{}"), None);
|
||||
assert_eq!(vpc_id_from_state(b"not json"), None);
|
||||
let hostile = json!({"outputs": {"vpc_id": {"value": "vpc-1\"; rm -rf /"}}}).to_string();
|
||||
assert_eq!(vpc_id_from_state(hostile.as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundles_contain_the_module_backend_vars_and_readme() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ok = owner_key(REGION, USER_ID);
|
||||
let d = Deployment::User { env: "dev", owner_key: &ok };
|
||||
let vars = onboarding_vars("dev", REGION, USER_ID);
|
||||
let written = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap();
|
||||
|
||||
for (name, text) in module_files(ModuleKind::UserOnboard) {
|
||||
assert_eq!(&fs::read_to_string(dir.path().join(name)).unwrap(), text);
|
||||
assert!(written.contains(&name.to_string()));
|
||||
}
|
||||
let bucket = format!("triple-c-tfstate-{ACCOUNT}-{REGION}");
|
||||
let key = state_key(d);
|
||||
let backend = fs::read_to_string(dir.path().join("backend.hcl")).unwrap();
|
||||
assert!(backend.contains(&format!("bucket = \"{bucket}\"")), "{backend}");
|
||||
assert!(backend.contains(&format!("key = \"{key}\"")), "{backend}");
|
||||
assert!(backend.contains("use_lockfile = true"), "{backend}");
|
||||
let tfvars: Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.path().join("terraform.tfvars.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(tfvars, vars);
|
||||
|
||||
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
|
||||
for needle in [
|
||||
bucket.as_str(),
|
||||
key.as_str(),
|
||||
ACCOUNT,
|
||||
REGION,
|
||||
"tofu init -backend-config=backend.hcl",
|
||||
"tofu apply",
|
||||
"terraform providers lock",
|
||||
"registry.opentofu.org",
|
||||
"1.10",
|
||||
"aws s3api create-bucket",
|
||||
] {
|
||||
assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}");
|
||||
}
|
||||
// Outside us-east-1 the bucket needs a location constraint.
|
||||
assert!(readme.contains(&format!("\"LocationConstraint\":\"{REGION}\"")), "{readme}");
|
||||
// The admin policy allows s3:CreateBucket only with the component tag
|
||||
// in the request, so the tag goes on the create itself; and the bucket
|
||||
// is configured as the app configures it, TLS-only policy included.
|
||||
let create = readme
|
||||
.lines()
|
||||
.find(|l| l.contains("aws s3api create-bucket"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
create.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#),
|
||||
"{create}"
|
||||
);
|
||||
for needle in [
|
||||
"put-bucket-versioning",
|
||||
"put-bucket-encryption",
|
||||
"put-public-access-block",
|
||||
"put-bucket-policy",
|
||||
"aws:SecureTransport",
|
||||
"DenyInsecureTransport",
|
||||
] {
|
||||
assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}");
|
||||
}
|
||||
assert!(!readme.contains("put-bucket-tagging"), "a later tag can't satisfy the create");
|
||||
|
||||
// Writing again over our own files is fine...
|
||||
write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap();
|
||||
// ...but an unrelated file (or folder) means the wrong folder.
|
||||
fs::write(dir.path().join("notes.txt"), "mine").unwrap();
|
||||
let e = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap_err();
|
||||
assert!(e.contains("notes.txt"), "{e}");
|
||||
assert_eq!(fs::read_to_string(dir.path().join("notes.txt")).unwrap(), "mine");
|
||||
fs::remove_file(dir.path().join("notes.txt")).unwrap();
|
||||
fs::create_dir(dir.path().join(".terraform")).unwrap();
|
||||
assert!(write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_us_east_1_bundle_creates_its_bucket_without_a_location_constraint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vars = environment_vars("dev", "us-east-1", &NetworkChoice::Public).unwrap();
|
||||
write_bundle(dir.path(), Deployment::Environment("dev"), ACCOUNT, "us-east-1", &vars).unwrap();
|
||||
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
|
||||
assert!(readme.contains("aws s3api create-bucket"), "{readme}");
|
||||
assert!(!readme.contains("LocationConstraint"), "{readme}");
|
||||
assert!(readme.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#), "{readme}");
|
||||
assert!(readme.contains("tofu destroy"), "{readme}");
|
||||
for (name, _) in module_files(ModuleKind::Environment) {
|
||||
assert!(dir.path().join(name).is_file(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_ids_must_look_like_an_aws_user_id() {
|
||||
for ok in ["AIDAEXAMPLEUSERID1234", "AROAEXAMPLEID12345678:alice", "AROA1234567890ABCDEF:a.b@c_d-e+f=g,h"] {
|
||||
assert_eq!(validate_owner_id(ok).unwrap(), ok);
|
||||
}
|
||||
for bad in [
|
||||
"",
|
||||
"123456789012",
|
||||
"aidaexampleuserid1234",
|
||||
"AROAEXAMPLEID12345678:",
|
||||
"AROAEXAMPLEID12345678:a b",
|
||||
"AROAEXAMPLEID12345678:a:b",
|
||||
"AROAEXAMPLEID12345678:\"",
|
||||
&format!("AROAEXAMPLEID12345678:{}", "a".repeat(65)),
|
||||
&"A".repeat(129),
|
||||
] {
|
||||
assert!(validate_owner_id(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_side_rechecks_follow_the_detected_actions() {
|
||||
use EnvironmentAction::*;
|
||||
let s = scan(
|
||||
Knowable::Yes,
|
||||
vec![
|
||||
env("broken", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
|
||||
env(
|
||||
"working",
|
||||
EnvironmentState::Incomplete { lock: Some(lock(9000, false)) },
|
||||
vec![Repair, Unlock],
|
||||
),
|
||||
env("ready", EnvironmentState::Ready, vec![Join]),
|
||||
env("busy", EnvironmentState::Deploying { lock: Some(lock(10, true)) }, vec![Follow]),
|
||||
],
|
||||
);
|
||||
assert!(require_action(&s, "broken", Destroy).is_ok());
|
||||
assert!(require_action(&s, "working", Destroy).is_err(), "never destroy a working env (D6)");
|
||||
assert!(require_action(&s, "ready", Destroy).is_err());
|
||||
assert!(require_action(&s, "missing", Destroy).is_err());
|
||||
assert!(require_action(&s, "working", Repair).is_ok());
|
||||
assert!(require_action(&s, "ready", Update).is_err());
|
||||
|
||||
assert!(unlock_refusal(&s, "working").is_ok());
|
||||
assert!(unlock_refusal(&s, "broken").is_err(), "no lock to remove");
|
||||
assert!(unlock_refusal(&s, "busy").is_err(), "a local runner holds it");
|
||||
let local_stale = scan(
|
||||
Knowable::Yes,
|
||||
vec![env(
|
||||
"x",
|
||||
EnvironmentState::Incomplete { lock: Some(lock(9000, true)) },
|
||||
vec![Unlock],
|
||||
)],
|
||||
);
|
||||
assert!(unlock_refusal(&local_stale, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_needs_a_joinable_environment_and_the_boundary() {
|
||||
use EnvironmentAction::*;
|
||||
let mut ready = env("dev", EnvironmentState::Ready, vec![Join]);
|
||||
ready.onboarding = Some(OnboardingState::NotJoined);
|
||||
let mut busy = env("busy", EnvironmentState::Ready, vec![Join]);
|
||||
busy.onboarding = Some(OnboardingState::InProgress { lock: None });
|
||||
let half = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
|
||||
let s = scan(Knowable::Yes, vec![ready.clone(), busy, half]);
|
||||
assert!(onboard_refusal(&s, "dev").is_ok());
|
||||
assert!(onboard_refusal(&s, "busy").is_err());
|
||||
assert!(onboard_refusal(&s, "half").is_err());
|
||||
assert!(onboard_refusal(&s, "nope").is_err());
|
||||
assert!(onboard_refusal(&scan(Knowable::No, vec![ready]), "dev").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_events_have_the_documented_shape() {
|
||||
let p = DeployProgress {
|
||||
op_id: "op".into(),
|
||||
state_key: "boundary/terraform.tfstate".into(),
|
||||
events: vec![],
|
||||
done: false,
|
||||
cancellable: true,
|
||||
result: None,
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"op_id": "op", "state_key": "boundary/terraform.tfstate", "events": [], "done": false, "cancellable": true})
|
||||
);
|
||||
let done = DeployProgress {
|
||||
done: true,
|
||||
result: Some(TofuOutcome { ok: false, exit_code: 1, summary: Some("boom".into()) }),
|
||||
..p
|
||||
};
|
||||
let v = serde_json::to_value(&done).unwrap();
|
||||
assert_eq!(v["result"], json!({"ok": false, "exit_code": 1, "summary": "boom"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_running_start_names_the_deployment_and_suggests_follow() {
|
||||
let e = start_error(StartError::AlreadyRunning, "environment \"dev\"");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("already running for environment \"dev\""), "{msg}");
|
||||
assert!(msg.contains("Follow"), "{msg}");
|
||||
let inner = AwsError::KeysMissing;
|
||||
assert_eq!(start_error(StartError::Failed(inner.clone()), "x"), inner);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_follower_emits_nothing_until_its_gate_opens() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
// A finished run: the whole replay and `done` are available at once,
|
||||
// the case where events could otherwise beat the op id to the caller.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lines: String = (0..450).map(|i| format!("line {i}\n")).collect();
|
||||
fs::write(dir.path().join("apply.log"), lines).unwrap();
|
||||
fs::write(dir.path().join("apply.exit"), "0\n").unwrap();
|
||||
let wd = Workdir { dir: dir.path().to_path_buf() };
|
||||
let base = DeployProgress {
|
||||
op_id: "op-1".into(),
|
||||
state_key: "boundary/terraform.tfstate".into(),
|
||||
events: vec![],
|
||||
done: false,
|
||||
cancellable: true,
|
||||
result: None,
|
||||
};
|
||||
let seen: Arc<Mutex<Vec<DeployProgress>>> = Arc::default();
|
||||
let sink = seen.clone();
|
||||
let (gate, rx) = Gate::new();
|
||||
let task = tokio::spawn(follow_and_report(
|
||||
rx,
|
||||
wd,
|
||||
base,
|
||||
Duration::from_millis(10),
|
||||
move |p| sink.lock().unwrap().push(p.clone()),
|
||||
));
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
assert!(seen.lock().unwrap().is_empty(), "emitted before the gate opened");
|
||||
|
||||
gate.open();
|
||||
task.await.unwrap();
|
||||
let seen = seen.lock().unwrap();
|
||||
let batches: Vec<usize> = seen.iter().filter(|p| !p.done).map(|p| p.events.len()).collect();
|
||||
assert_eq!(batches, vec![200, 200, 50], "the whole log, in capped batches");
|
||||
assert!(seen.iter().all(|p| p.op_id == "op-1" && p.state_key == "boundary/terraform.tfstate"));
|
||||
let last = seen.last().unwrap();
|
||||
assert!(last.done && !last.cancellable);
|
||||
assert_eq!(last.result, Some(TofuOutcome { ok: true, exit_code: 0, summary: None }));
|
||||
assert_eq!(seen.iter().filter(|p| p.done).count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dropped_gate_still_releases_the_follower() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("apply.log"), "x\n").unwrap();
|
||||
fs::write(dir.path().join("apply.exit"), "1\n").unwrap();
|
||||
let (gate, rx) = Gate::new();
|
||||
drop(gate);
|
||||
let mut n = 0;
|
||||
follow_and_report(
|
||||
rx,
|
||||
Workdir { dir: dir.path().to_path_buf() },
|
||||
DeployProgress {
|
||||
op_id: "o".into(),
|
||||
state_key: "k".into(),
|
||||
events: vec![],
|
||||
done: false,
|
||||
cancellable: false,
|
||||
result: None,
|
||||
},
|
||||
Duration::from_millis(10),
|
||||
|_| n += 1,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_needs_the_action_no_record_and_no_lock() {
|
||||
use EnvironmentAction::*;
|
||||
let mut stale_with_record = env(
|
||||
"rec",
|
||||
EnvironmentState::Incomplete { lock: Some(lock(9000, false)) },
|
||||
vec![Repair, Destroy, Unlock],
|
||||
);
|
||||
// Stale lock + record + no state: detection offers Destroy; the guard
|
||||
// must still refuse (the record says it may be working).
|
||||
stale_with_record.info = Some(info("rec", "public", true));
|
||||
let mut record_unlocked = env("rec2", EnvironmentState::Incomplete { lock: None }, vec![Destroy]);
|
||||
record_unlocked.info = Some(info("rec2", "public", true));
|
||||
let s = scan(
|
||||
Knowable::Yes,
|
||||
vec![
|
||||
stale_with_record,
|
||||
record_unlocked,
|
||||
env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Destroy, Unlock]),
|
||||
env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
|
||||
env("ready", EnvironmentState::Ready, vec![Join]),
|
||||
],
|
||||
);
|
||||
let e = destroy_refusal(&s, "rec").unwrap_err();
|
||||
assert!(e.contains("valid record"), "{e}");
|
||||
assert!(destroy_refusal(&s, "rec2").unwrap_err().contains("valid record"));
|
||||
assert!(destroy_refusal(&s, "locked").unwrap_err().contains("Unlock it first"));
|
||||
assert!(destroy_refusal(&s, "ready").is_err());
|
||||
assert!(destroy_refusal(&s, "missing").is_err());
|
||||
assert_eq!(destroy_refusal(&s, "half").unwrap().name, "half");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_is_refused_while_users_are_onboarded() {
|
||||
use EnvironmentAction::*;
|
||||
let mut onboarded = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
|
||||
onboarded.onboarded_users = Some(2);
|
||||
let mut unknown = env("blind", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
|
||||
unknown.onboarded_users = None;
|
||||
let s = scan(Knowable::Yes, vec![onboarded, unknown]);
|
||||
let e = destroy_refusal(&s, "half").unwrap_err();
|
||||
assert!(e.contains("remove the onboarded users' stacks first"), "{e}");
|
||||
assert!(e.contains("users/half/"), "{e}");
|
||||
// Without a listing there's no telling, and nothing to destroy from.
|
||||
assert!(destroy_refusal(&s, "blind").unwrap_err().contains("can't list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_your_own_stale_onboarding_lock_can_be_removed() {
|
||||
use EnvironmentAction::*;
|
||||
let mut stale = env("dev", EnvironmentState::Ready, vec![Join]);
|
||||
stale.onboarding = Some(OnboardingState::Incomplete { lock: Some(lock(9000, false)) });
|
||||
stale.onboarding_actions = vec![Unlock];
|
||||
let mut live = env("live", EnvironmentState::Ready, vec![Join]);
|
||||
live.onboarding = Some(OnboardingState::InProgress { lock: Some(lock(10, false)) });
|
||||
let mut none = env("none", EnvironmentState::Ready, vec![Join]);
|
||||
none.onboarding = Some(OnboardingState::Incomplete { lock: None });
|
||||
let s = scan(Knowable::Yes, vec![stale, live, none]);
|
||||
let mine = owner_key(REGION, USER_ID);
|
||||
assert!(onboarding_unlock_refusal(&s, "dev", &mine).is_ok());
|
||||
let e = onboarding_unlock_refusal(&s, "dev", "0123456789ab").unwrap_err();
|
||||
assert!(e.contains("your own"), "{e}");
|
||||
assert!(onboarding_unlock_refusal(&s, "live", &mine).is_err());
|
||||
assert!(onboarding_unlock_refusal(&s, "none", &mine).is_err());
|
||||
assert!(onboarding_unlock_refusal(&s, "missing", &mine).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_bundle_names_an_administrator_without_the_admin_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_bundle(dir.path(), Deployment::Boundary, ACCOUNT, REGION, &boundary_vars(REGION)).unwrap();
|
||||
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
|
||||
assert!(readme.contains("account administrator"), "{readme}");
|
||||
assert!(readme.contains("WITHOUT"), "{readme}");
|
||||
assert!(!readme.contains("that carry the Triple-C environment-admin"), "{readme}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_and_update_are_refused_under_a_held_lock() {
|
||||
use EnvironmentAction::*;
|
||||
let s = scan(
|
||||
Knowable::Yes,
|
||||
vec![
|
||||
env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Unlock]),
|
||||
env("free", EnvironmentState::Incomplete { lock: None }, vec![Repair]),
|
||||
env("old", EnvironmentState::UpdateAvailable, vec![Join, Update]),
|
||||
],
|
||||
);
|
||||
let e = reapply_refusal(&s, "locked", Repair).unwrap_err();
|
||||
assert!(e.contains("Unlock it first"), "{e}");
|
||||
assert!(reapply_refusal(&s, "free", Repair).is_ok());
|
||||
assert!(reapply_refusal(&s, "old", Update).is_ok());
|
||||
assert!(reapply_refusal(&s, "free", Update).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_deletes_only_a_still_stale_lock_and_reads_412_as_changed() {
|
||||
assert!(stale_lock_refusal(0).is_err());
|
||||
assert!(stale_lock_refusal(LIVE_LOCK_SECS - 1).is_err());
|
||||
assert!(stale_lock_refusal(LIVE_LOCK_SECS).is_ok());
|
||||
assert!(is_precondition_failed(Some("PreconditionFailed"), Some(412)));
|
||||
assert!(is_precondition_failed(None, Some(412)));
|
||||
assert!(!is_precondition_failed(Some("AccessDenied"), Some(403)));
|
||||
assert!(!is_precondition_failed(None, Some(404)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_boundary_without_state_here_is_refused() {
|
||||
assert!(boundary_refusal(&Knowable::Yes, Some(false)).unwrap_err().contains("already exists"));
|
||||
assert!(boundary_refusal(&Knowable::Yes, Some(true)).is_ok(), "re-apply our own");
|
||||
assert!(boundary_refusal(&Knowable::Yes, None).is_ok(), "can't tell");
|
||||
assert!(boundary_refusal(&Knowable::No, Some(false)).is_ok());
|
||||
assert!(boundary_refusal(&Knowable::Unknown, Some(false)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_follow_is_refused_while_a_launch_holds_the_folder() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.begin_launch(dir).unwrap();
|
||||
let mut spawned = false;
|
||||
let r = slots.install_follower(dir, "f1", false, || {
|
||||
spawned = true;
|
||||
1
|
||||
});
|
||||
assert_eq!(r.unwrap_err(), SlotBusy::Launching);
|
||||
assert!(!spawned, "nothing is spawned for a refused follow");
|
||||
// The launch's own follower replaces its Launching slot.
|
||||
assert_eq!(slots.install_follower(dir, "l1", true, || 2).unwrap(), None);
|
||||
slots.end_launch(dir); // the guard dropping afterwards
|
||||
assert!(!slots.is_free(dir), "end_launch must not clear a follower");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_is_refused_while_a_follower_or_launch_holds_the_folder() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.install_follower(dir, "f1", false, || 1).unwrap();
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following);
|
||||
slots.finish_follower(dir, "f1");
|
||||
slots.begin_launch(dir).unwrap();
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching);
|
||||
slots.end_launch(dir);
|
||||
assert!(slots.is_free(dir));
|
||||
// Other folders are independent.
|
||||
slots.begin_launch(dir).unwrap();
|
||||
slots.begin_launch(Path::new("/w/env/prod")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finishing_follower_only_removes_its_own_slot() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.install_follower(dir, "old", false, || 1).unwrap();
|
||||
// A re-follow replaces it and hands back the old task to abort.
|
||||
assert_eq!(slots.install_follower(dir, "new", false, || 2).unwrap(), Some(1));
|
||||
slots.finish_follower(dir, "old");
|
||||
assert!(!slots.is_free(dir), "the old follower must not remove the new one's slot");
|
||||
slots.finish_follower(dir, "new");
|
||||
assert!(slots.is_free(dir));
|
||||
// Nor a launch's slot.
|
||||
slots.begin_launch(dir).unwrap();
|
||||
slots.finish_follower(dir, "new");
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ending_a_launch_never_clears_a_following_slot() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.install_follower(dir, "f", false, || 1).unwrap();
|
||||
slots.end_launch(dir);
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following);
|
||||
slots.end_launch(Path::new("/w/env/none")); // nothing there: no-op
|
||||
}
|
||||
@@ -4,14 +4,11 @@ use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine as _;
|
||||
use bollard::container::{DownloadFromContainerOptions, LogOutput};
|
||||
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, OUTPUT_LIMIT_MARKER};
|
||||
use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
@@ -63,9 +60,7 @@ pub async fn list_container_files(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
// `exec_oneshot` discards the exit code, which is how a `find` that listed
|
||||
@@ -78,7 +73,8 @@ pub async fn list_container_files(
|
||||
// are the error message, its `-printf` records are the listing, and the
|
||||
// parser should never be handed the former.
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", list_argv(&path), Vec::new())
|
||||
target
|
||||
.exec_oneshot_streams_as("claude", list_argv(&path), Vec::new())
|
||||
.await
|
||||
.map_err(|e| describe_listing_failure(&path, e))?;
|
||||
|
||||
@@ -156,7 +152,7 @@ fn list_argv(path: &str) -> Vec<String> {
|
||||
/// folder can act on.
|
||||
///
|
||||
/// One case is worth naming: a directory with more entries than
|
||||
/// [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
|
||||
/// [`crate::host::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
|
||||
/// the point where a `find` record set passes 8 MiB, and what the panel showed
|
||||
/// was "Command output exceeded 8388608 bytes and was abandoned" — a true
|
||||
/// statement about a buffer, and no help at all about a directory.
|
||||
@@ -430,7 +426,11 @@ pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<()
|
||||
/// and the lexical answer stands: failing every write closed would break the
|
||||
/// panel outright for a risk the container user does not need this code path to
|
||||
/// take.
|
||||
async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Result<(), String> {
|
||||
async fn resolve_container_dir(
|
||||
target: &ContainerTarget,
|
||||
what: &str,
|
||||
dir: &str,
|
||||
) -> Result<(), String> {
|
||||
validate_container_write_path(what, dir)?;
|
||||
|
||||
// Split streams, not the combined buffer: `realpath`'s answer is a *path*
|
||||
@@ -438,18 +438,18 @@ async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Res
|
||||
// hazard the listing above took apart for `find`. A warning on stderr —
|
||||
// and there is one whenever a component is unreadable — used to be spliced
|
||||
// into the string this then compared against the write roots.
|
||||
let (stdout, diagnostics, code) = exec_oneshot_streams_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec![
|
||||
"realpath".to_string(),
|
||||
"-m".to_string(),
|
||||
"--".to_string(),
|
||||
dir.to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
let (stdout, diagnostics, code) = target
|
||||
.exec_oneshot_streams_as(
|
||||
"claude",
|
||||
vec![
|
||||
"realpath".to_string(),
|
||||
"-m".to_string(),
|
||||
"--".to_string(),
|
||||
dir.to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let resolved = stdout.trim();
|
||||
if code != 0 || resolved.is_empty() {
|
||||
@@ -1188,7 +1188,7 @@ pub(crate) struct FetchedFile {
|
||||
|
||||
/// Fetch a single regular file from a container as exact bytes.
|
||||
///
|
||||
/// The viewer's read. It deliberately goes through Docker's archive endpoint rather than
|
||||
/// The viewer's read. It deliberately goes through the archive endpoint rather than
|
||||
/// `exec_oneshot`: that reader runs every chunk through `String::from_utf8_lossy`
|
||||
/// and merges stderr into stdout, so it would both corrupt any non-UTF-8 file
|
||||
/// and be able to splice diagnostics into what the caller believes is content.
|
||||
@@ -1203,18 +1203,11 @@ pub(crate) struct FetchedFile {
|
||||
/// read — landed in host RAM twice. This function buffers, so every caller of
|
||||
/// it must name a ceiling.
|
||||
pub(crate) async fn fetch_container_file(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
container_path: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<FetchedFile, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(DownloadFromContainerOptions {
|
||||
path: container_path.to_string(),
|
||||
}),
|
||||
);
|
||||
let mut stream = container.download_archive(container_path);
|
||||
|
||||
// A tar member is a 512-byte header plus payload padded to 512. 8 KiB of
|
||||
// slack past the payload cap guarantees the header and the whole capped
|
||||
@@ -1297,13 +1290,11 @@ pub async fn read_container_file(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES);
|
||||
let fetched = fetch_container_file(container_id, &path, cap).await?;
|
||||
let fetched = fetch_container_file(&target, &path, cap).await?;
|
||||
|
||||
Ok(FileContents {
|
||||
contents_base64: BASE64.encode(&fetched.bytes),
|
||||
@@ -1333,9 +1324,7 @@ pub async fn rename_container_path(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
// The name is checked by `validate_entry_name`; the path it is applied to
|
||||
@@ -1349,7 +1338,7 @@ pub async fn rename_container_path(
|
||||
// directory component to resolve.
|
||||
let parent = parent_dir(&from_path);
|
||||
if parent != "/" {
|
||||
resolve_container_dir(container_id, "Item", &parent).await?;
|
||||
resolve_container_dir(&target, "Item", &parent).await?;
|
||||
}
|
||||
|
||||
let new_name = to_path.trim();
|
||||
@@ -1364,8 +1353,7 @@ pub async fn rename_container_path(
|
||||
// and exits 0 — so `-n` on its own would report a rename that never
|
||||
// happened. The existence check is what turns it into an error the user
|
||||
// sees; `-n` stays as the belt-and-braces against the race between them.
|
||||
let (_, exists) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (_, exists) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["test".to_string(), "-e".to_string(), dest.clone()],
|
||||
Vec::new(),
|
||||
@@ -1375,8 +1363,7 @@ pub async fn rename_container_path(
|
||||
return Err(format!("\"{}\" already exists in this folder", new_name));
|
||||
}
|
||||
|
||||
let (output, code) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (output, code) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec![
|
||||
"mv".to_string(),
|
||||
@@ -1417,19 +1404,16 @@ pub async fn create_container_directory(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
resolve_container_dir(container_id, "Folder", &parent_path).await?;
|
||||
resolve_container_dir(&target, "Folder", &parent_path).await?;
|
||||
|
||||
let name = name.trim();
|
||||
validate_entry_name(name)?;
|
||||
let dest = join_path(&parent_path, name);
|
||||
|
||||
let (output, code) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (output, code) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["mkdir".to_string(), "--".to_string(), dest.clone()],
|
||||
Vec::new(),
|
||||
@@ -1459,20 +1443,19 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String {
|
||||
/// Refuse, in a sentence, before a Docker error has to speak for us.
|
||||
///
|
||||
/// Both file transfers and the backup run through `docker exec`, which needs a
|
||||
/// running container. Without this the failure surfaces as bollard's
|
||||
/// running container. Without this the failure surfaces as Docker's
|
||||
/// `is not running` wrapped in whatever the caller was doing, and for the
|
||||
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
|
||||
/// is the first thing to touch the container, so a stopped project fails inside
|
||||
/// path *validation* and reads like the path was the problem.
|
||||
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let running = docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info.state)
|
||||
.and_then(|s| s.running)
|
||||
.unwrap_or(false);
|
||||
pub(crate) async fn require_running(
|
||||
container: &ContainerTarget,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
// An unreachable daemon is an error; an inspect failure (container gone)
|
||||
// reads as "not running" — `is_running`'s contract, and what this did
|
||||
// when it inspected the container itself.
|
||||
let running = container.is_running().await?;
|
||||
if running {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1543,12 +1526,10 @@ pub async fn download_container_file(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
require_running(container_id, "saving a file to the host").await?;
|
||||
require_running(&target, "saving a file to the host").await?;
|
||||
|
||||
let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else {
|
||||
// Dismissed. Not an error, and deliberately distinguishable from one:
|
||||
@@ -1564,8 +1545,6 @@ pub async fn download_container_file(
|
||||
// costs. See the note on `pick_save_path` about which ones those are.
|
||||
let dest = resolve_host_path(&chosen, HostPathUse::WriteChosenName).await?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an
|
||||
// environment value, so a name containing a quote or a `$` is data.
|
||||
//
|
||||
@@ -1627,30 +1606,19 @@ esac
|
||||
echo "TC_SIZE=$SZ" >&2
|
||||
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(vec!["sh".to_string(), "-c".to_string(), script.to_string()]),
|
||||
env: Some(vec![format!("TC_SRC={}", container_path)]),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
// As `claude`, no TTY, no stdin and no working directory (the image's
|
||||
// default) — the options this exec has always been created with.
|
||||
let exec = target
|
||||
.attach(
|
||||
ExecSpec::new(vec!["sh".to_string(), "-c".to_string(), script.to_string()])
|
||||
.env(vec![format!("TC_SRC={}", container_path)])
|
||||
.working_dir(None)
|
||||
.no_stdin(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let mut output = match result {
|
||||
StartExecResults::Attached { output, .. } => output,
|
||||
StartExecResults::Detached => return Err("Download exec started detached".to_string()),
|
||||
};
|
||||
let exec_id = exec.exec_id;
|
||||
let mut output = exec.output;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let partial = partial_download_path(&dest)?;
|
||||
@@ -1696,7 +1664,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write {}: {}", dest.display(), e));
|
||||
break;
|
||||
@@ -1727,7 +1695,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
push_capped(&mut stderr_text, &message);
|
||||
if declared.is_none() {
|
||||
declared = parse_declared_size(&stderr_text);
|
||||
@@ -1760,7 +1728,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
// success renames a truncated partial over the file the user already had
|
||||
// and reports the byte count as if it were the whole thing. That is the one
|
||||
// way this command could destroy data, so silence is failure.
|
||||
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||
let exit_code = target.wait_exec_exit(&exec_id).await;
|
||||
if stream_err.is_none() && exit_code != Some(0) {
|
||||
// Framed and clipped, never verbatim.
|
||||
//
|
||||
@@ -1894,7 +1862,7 @@ pub struct UploadOutcome {
|
||||
/// * the destination goes through [`resolve_container_dir`], so it is inside
|
||||
/// [`CONTAINER_WRITE_ROOTS`] both as written and as it resolves;
|
||||
/// * the read, the size ceiling and the descriptor check are
|
||||
/// `docker::exec::upload_host_file_with_ids`', unchanged — including
|
||||
/// `ContainerTarget::upload_host_file_with_ids`', unchanged — including
|
||||
/// landing the file owned by the container user rather than root.
|
||||
///
|
||||
/// The dialog itself is opened from Rust ([`pick_files_to_upload`]), which is
|
||||
@@ -1911,15 +1879,13 @@ pub async fn upload_files_to_container(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
// Both checks before the dialog, so a project that cannot receive files
|
||||
// says so instead of asking the user to choose some first.
|
||||
require_running(container_id, "uploading files").await?;
|
||||
resolve_container_dir(container_id, "Upload", &container_dir).await?;
|
||||
require_running(&target, "uploading files").await?;
|
||||
resolve_container_dir(&target, "Upload", &container_dir).await?;
|
||||
|
||||
let Some(picked) = pick_files_to_upload(&window).await else {
|
||||
return Ok(None);
|
||||
@@ -1930,14 +1896,14 @@ pub async fn upload_files_to_container(
|
||||
// files; this one is the check that actually guards the extraction. A modal
|
||||
// has no time limit, and `resolve_container_dir` answers a question about
|
||||
// the container's filesystem — which the container is free to change while
|
||||
// the dialog is open. Without this, `docker::exec`'s doc comment claim that
|
||||
// the dialog is open. Without this, the uploader's doc comment claim that
|
||||
// the destination "has already been confirmed" would be true only of a
|
||||
// moment that had passed.
|
||||
resolve_container_dir(container_id, "Upload", &container_dir).await?;
|
||||
resolve_container_dir(&target, "Upload", &container_dir).await?;
|
||||
|
||||
// Once for the whole selection, not once per file — see
|
||||
// `upload_host_file_with_ids`.
|
||||
let ids = crate::docker::exec::container_user_ids(container_id).await;
|
||||
let ids = target.user_ids().await;
|
||||
|
||||
let mut outcome = UploadOutcome {
|
||||
uploaded: Vec::new(),
|
||||
@@ -1951,7 +1917,7 @@ pub async fn upload_files_to_container(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match upload_one(container_id, &path, &container_dir, ids).await {
|
||||
match upload_one(&target, &path, &container_dir, ids).await {
|
||||
Ok(dest) => {
|
||||
log::info!("Uploaded {} into project {} at {}", path, project_id, dest);
|
||||
outcome.uploaded.push(dest);
|
||||
@@ -1966,7 +1932,7 @@ pub async fn upload_files_to_container(
|
||||
/// naming the file, because the caller may be reporting several at once and
|
||||
/// "is a folder" on its own does not say which one.
|
||||
async fn upload_one(
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
host_path: &str,
|
||||
container_dir: &str,
|
||||
ids: (u64, u64),
|
||||
@@ -1990,7 +1956,7 @@ async fn upload_one(
|
||||
// The ceiling is enforced against the open descriptor inside the uploader;
|
||||
// this copy of it exists so the refusal arrives as a sentence instead of
|
||||
// after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
use crate::host::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"{} is too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
@@ -2000,14 +1966,9 @@ async fn upload_one(
|
||||
));
|
||||
}
|
||||
|
||||
crate::docker::exec::upload_host_file_with_ids(
|
||||
container_id,
|
||||
&resolved,
|
||||
container_dir,
|
||||
&name,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
target
|
||||
.upload_host_file_with_ids(&resolved, container_dir, &name, ids)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Container-authored diagnostic text, cut down to something safe to quote.
|
||||
@@ -2274,14 +2235,10 @@ pub async fn download_container_backup(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
require_running(container_id, "backing up").await?;
|
||||
require_running(&target, "backing up").await?;
|
||||
|
||||
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
|
||||
// Read-only source: `tar -C` it, so absoluteness and `..` are what matter.
|
||||
@@ -2326,33 +2283,22 @@ tar czf - --ignore-failed-read \
|
||||
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
env: Some(vec![
|
||||
// As `claude`, no TTY, no stdin and no working directory (the image's
|
||||
// default) — the options this exec has always been created with.
|
||||
let exec = target
|
||||
.attach(
|
||||
ExecSpec::new(cmd)
|
||||
.env(vec![
|
||||
"HOME=/home/claude".to_string(),
|
||||
format!("TC_BACKUP_SRC={}", path),
|
||||
]),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
])
|
||||
.working_dir(None)
|
||||
.no_stdin(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
|
||||
|
||||
let mut output = match result {
|
||||
StartExecResults::Attached { output, .. } => output,
|
||||
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
|
||||
};
|
||||
.map_err(|e| format!("Failed to start backup: {}", e))?;
|
||||
let exec_id = exec.exec_id;
|
||||
let mut output = exec.output;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let partial = partial_download_path(&dest)?;
|
||||
@@ -2401,14 +2347,14 @@ tar czf - --ignore-failed-read \
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write backup file: {}", e));
|
||||
break;
|
||||
}
|
||||
total += message.len() as u64;
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
push_capped(&mut stderr_text, &message);
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -2430,7 +2376,7 @@ tar czf - --ignore-failed-read \
|
||||
// when `total > 0`. Poll until the exec actually reports finished so the
|
||||
// exit code is reliably populated; if it can't be determined we fall back to
|
||||
// the `total == 0` check below.
|
||||
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||
let exit_code = target.wait_exec_exit(&exec_id).await;
|
||||
|
||||
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
|
||||
stream_err = Some(format!(
|
||||
@@ -3564,10 +3510,11 @@ mod tests {
|
||||
|
||||
// `/workspace/escape` is under a write root as a string and is `/etc` as
|
||||
// a location.
|
||||
let escaped = resolve_container_dir(&name, "Folder", "/workspace/escape").await;
|
||||
let target = ContainerTarget::docker(name.clone());
|
||||
let escaped = resolve_container_dir(&target, "Folder", "/workspace/escape").await;
|
||||
println!("container escape: {:?}", escaped);
|
||||
assert!(escaped.is_err(), "a symlink out of /workspace was accepted");
|
||||
resolve_container_dir(&name, "Folder", "/workspace")
|
||||
resolve_container_dir(&target, "Folder", "/workspace")
|
||||
.await
|
||||
.expect("/workspace itself");
|
||||
|
||||
@@ -3575,7 +3522,7 @@ mod tests {
|
||||
// reporting itself empty — `-H` follows the start point, and only the
|
||||
// start point.
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace/link"), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv("/workspace/link"), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
println!("link: code={} diagnostics={:?}", code, diagnostics);
|
||||
@@ -3588,7 +3535,7 @@ mod tests {
|
||||
// The link itself is still *marked* as one in its parent's listing, and
|
||||
// still navigable.
|
||||
let (records, _, _) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace"), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv("/workspace"), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = parse_find_output("/workspace", &records);
|
||||
@@ -3607,7 +3554,7 @@ mod tests {
|
||||
for path in ["/workspace/broken", "/workspace/loop"] {
|
||||
let started = std::time::Instant::now();
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv(path), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv(path), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let took = started.elapsed();
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::file_viewer::registry::{
|
||||
use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates};
|
||||
use crate::file_viewer::window::open_viewer_window;
|
||||
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::Project;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -113,13 +114,11 @@ fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
|
||||
}
|
||||
|
||||
/// `action` completes "Start the project before …", e.g. "saving this file".
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
|
||||
let container_id = project
|
||||
.container_id
|
||||
.clone()
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<ContainerTarget, String> {
|
||||
let container = ContainerTarget::for_project(project)
|
||||
.ok_or_else(|| not_running_message(action, "files live in its container"))?;
|
||||
require_running(&container_id, action).await?;
|
||||
Ok(container_id)
|
||||
require_running(&container, action).await?;
|
||||
Ok(container)
|
||||
}
|
||||
|
||||
/// The container of the project a viewer window belongs to, if it is running.
|
||||
@@ -127,7 +126,7 @@ async fn running_container_for(
|
||||
state: &AppState,
|
||||
target: &ViewerTarget,
|
||||
action: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
running_container_of(&project_of(state, &target.project_id)?, action).await
|
||||
}
|
||||
|
||||
@@ -156,17 +155,17 @@ pub async fn open_file_viewer(
|
||||
) -> Result<(), String> {
|
||||
require_main(window.label())?;
|
||||
let project = project_of(&state, &project_id)?;
|
||||
let container_id = running_container_of(&project, "opening files").await?;
|
||||
let container = running_container_of(&project, "opening files").await?;
|
||||
|
||||
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
|
||||
let mut candidates = candidate_paths(&path, &mounts)?;
|
||||
let mut matches = probe_candidates(&container_id, &candidates).await?;
|
||||
let mut matches = probe_candidates(&container, &candidates).await?;
|
||||
// Not in the project: try home and /tmp, as a second exec so a project file
|
||||
// never becomes a chooser because a namesake sits in /tmp.
|
||||
if matches.is_empty() {
|
||||
let fallbacks = fallback_candidate_paths(&path);
|
||||
if !fallbacks.is_empty() {
|
||||
matches = probe_candidates(&container_id, &fallbacks).await?;
|
||||
matches = probe_candidates(&container, &fallbacks).await?;
|
||||
candidates.extend(fallbacks);
|
||||
}
|
||||
}
|
||||
@@ -253,9 +252,9 @@ pub async fn viewer_read_file(
|
||||
) -> Result<ViewerFile, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "opening files").await?;
|
||||
let container = running_container_for(&state, &target, "opening files").await?;
|
||||
let cap = max_bytes.clamp(1, MAX_READ_BYTES);
|
||||
let fetched = fetch_container_file(&container_id, &path, cap).await?;
|
||||
let fetched = fetch_container_file(&container, &path, cap).await?;
|
||||
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
|
||||
Ok(()) => (true, None),
|
||||
Err(reason) => (false, Some(reason)),
|
||||
@@ -278,8 +277,8 @@ pub async fn viewer_poll_file(
|
||||
) -> Result<ViewerPoll, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "checking this file for changes").await?;
|
||||
poll_file(&container_id, &path).await
|
||||
let container = running_container_for(&state, &target, "checking this file for changes").await?;
|
||||
poll_file(&container, &path).await
|
||||
}
|
||||
|
||||
/// Errors from `write_file` pass through unchanged: the frontend matches the
|
||||
@@ -302,8 +301,8 @@ pub async fn viewer_write_file(
|
||||
let bytes = BASE64
|
||||
.decode(contents_base64.as_bytes())
|
||||
.map_err(|_| "The editor sent malformed content.".to_string())?;
|
||||
let container_id = running_container_for(&state, &target, "saving this file").await?;
|
||||
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
|
||||
let container = running_container_for(&state, &target, "saving this file").await?;
|
||||
write_file(&container, &state.exec_manager, &path, &bytes, &base_hash).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
//! Commands for host connections (AWS): add, update, remove, identity,
|
||||
//! sign-in and the permission check.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::aws::permissions::{check_permissions, PermissionReport, SdkPermissionApi};
|
||||
use crate::aws::policies::{policy_files, Tier};
|
||||
use crate::aws::{caller_identity, sdk_config, sdk_config_for_keys, AwsError, CallerIdentity};
|
||||
use crate::models::{
|
||||
validate_connection_name, validate_profile_name, validate_supported_region, AppSettings, AwsAuth,
|
||||
HostConnection, HostKind,
|
||||
};
|
||||
use crate::storage::secure::{self, AwsKeys};
|
||||
use crate::storage::settings_store::SettingsStore;
|
||||
use crate::AppState;
|
||||
|
||||
fn other(message: impl Into<String>) -> AwsError {
|
||||
AwsError::Other { message: message.into() }
|
||||
}
|
||||
|
||||
fn check_id(id: &str) -> Result<(), String> {
|
||||
if secure::is_valid_connection_id(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Invalid connection id".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What the identity check is asked to authenticate with.
|
||||
pub(crate) enum Credentials {
|
||||
/// A named host profile (the connection itself).
|
||||
Profile(HostConnection),
|
||||
/// Keys not yet in the keychain, and the region to call in.
|
||||
Keys(AwsKeys, String),
|
||||
}
|
||||
|
||||
/// Validate-free core of both add commands: `GetCallerIdentity` first, and
|
||||
/// only on success keys are stored and the connection saved. An expired SSO
|
||||
/// session on a profile still saves (the UI then offers "Sign in").
|
||||
pub(crate) async fn add_connection_with<I, Fut>(
|
||||
conn: HostConnection,
|
||||
keys: Option<AwsKeys>,
|
||||
identity: I,
|
||||
store_keys: impl Fn(&str, &AwsKeys) -> Result<(), String>,
|
||||
delete_keys: impl Fn(&str) -> Result<(), String>,
|
||||
settings: &SettingsStore,
|
||||
) -> Result<HostConnection, AwsError>
|
||||
where
|
||||
I: FnOnce(Credentials) -> Fut,
|
||||
Fut: Future<Output = Result<CallerIdentity, AwsError>>,
|
||||
{
|
||||
let HostKind::Aws { auth, default_region } = &conn.kind;
|
||||
let credentials = match (&keys, auth) {
|
||||
(Some(k), AwsAuth::Keys { .. }) => Credentials::Keys(k.clone(), default_region.clone()),
|
||||
(None, AwsAuth::Profile { .. }) => Credentials::Profile(conn.clone()),
|
||||
_ => return Err(other("Connection credentials do not match its auth method")),
|
||||
};
|
||||
match identity(credentials).await {
|
||||
Ok(_) => {}
|
||||
Err(AwsError::SsoExpired { .. }) if keys.is_none() => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
if let Some(k) = &keys {
|
||||
store_keys(&conn.id, k).map_err(other)?;
|
||||
}
|
||||
if let Err(e) = settings.modify(|all| {
|
||||
all.hosts.push(conn.clone());
|
||||
Ok(())
|
||||
}) {
|
||||
if keys.is_some() {
|
||||
let _ = delete_keys(&conn.id);
|
||||
}
|
||||
return Err(other(e));
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Removes the stored connection with this id, however malformed its other
|
||||
/// fields are (a broken entry must stay removable). Its keychain entry is
|
||||
/// deleted only for a `Keys` connection whose `keyring_ref` is its own valid id.
|
||||
pub(crate) fn remove_connection_with(
|
||||
id: &str,
|
||||
delete_keys: impl Fn(&str) -> Result<(), String>,
|
||||
settings: &SettingsStore,
|
||||
) -> Result<AppSettings, String> {
|
||||
check_id(id)?;
|
||||
let existing = settings
|
||||
.get()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.find(|h| h.id == id)
|
||||
.ok_or_else(|| "Connection not found".to_string())?;
|
||||
let HostKind::Aws { auth, .. } = &existing.kind;
|
||||
if let AwsAuth::Keys { keyring_ref } = auth {
|
||||
if keyring_ref == id && secure::is_valid_connection_id(keyring_ref) {
|
||||
delete_keys(keyring_ref)?;
|
||||
}
|
||||
}
|
||||
settings
|
||||
.modify(|s| {
|
||||
s.hosts.retain(|h| h.id != id);
|
||||
Ok(())
|
||||
})
|
||||
.map(|(saved, ())| saved)
|
||||
}
|
||||
|
||||
async fn real_identity(c: Credentials) -> Result<CallerIdentity, AwsError> {
|
||||
let cfg = match c {
|
||||
Credentials::Profile(conn) => {
|
||||
let HostKind::Aws { default_region, .. } = &conn.kind;
|
||||
sdk_config(&conn, default_region).await?
|
||||
}
|
||||
Credentials::Keys(keys, region) => sdk_config_for_keys(&keys, ®ion),
|
||||
};
|
||||
caller_identity(&cfg).await
|
||||
}
|
||||
|
||||
pub(crate) fn aws_conn(id: &str, settings: &AppSettings) -> Result<HostConnection, String> {
|
||||
check_id(id)?;
|
||||
let conn = settings
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "Connection not found".to_string())?;
|
||||
// Defense in depth: stored values are re-checked before they reach the
|
||||
// keychain, the SDK or a subprocess.
|
||||
let HostKind::Aws { auth, default_region } = &conn.kind;
|
||||
validate_supported_region(default_region)?;
|
||||
match auth {
|
||||
AwsAuth::Profile { name } => {
|
||||
validate_profile_name(name)?;
|
||||
}
|
||||
AwsAuth::Keys { keyring_ref } if keyring_ref != id => {
|
||||
return Err("Connection's keychain reference does not match its id".to_string());
|
||||
}
|
||||
AwsAuth::Keys { .. } => {}
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn validated(name: &str, region: &str) -> Result<(String, String), AwsError> {
|
||||
Ok((
|
||||
validate_connection_name(name).map_err(other)?,
|
||||
validate_supported_region(region).map_err(other)?,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_aws_profile_connection(
|
||||
name: String,
|
||||
profile: String,
|
||||
default_region: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HostConnection, AwsError> {
|
||||
let (name, default_region) = validated(&name, &default_region)?;
|
||||
let profile = validate_profile_name(&profile).map_err(other)?;
|
||||
let conn = HostConnection {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
kind: HostKind::Aws { auth: AwsAuth::Profile { name: profile }, default_region },
|
||||
};
|
||||
add_connection_with(
|
||||
conn,
|
||||
None,
|
||||
real_identity,
|
||||
secure::store_aws_keys,
|
||||
secure::delete_aws_keys,
|
||||
&state.settings_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_aws_keys_connection(
|
||||
name: String,
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
session_token: Option<String>,
|
||||
default_region: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HostConnection, AwsError> {
|
||||
let (name, default_region) = validated(&name, &default_region)?;
|
||||
let access_key_id = access_key_id.trim().to_string();
|
||||
let secret_access_key = secret_access_key.trim().to_string();
|
||||
let session_token = session_token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
if access_key_id.is_empty() || secret_access_key.is_empty() {
|
||||
return Err(other("Access key ID and secret access key are required"));
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let conn = HostConnection {
|
||||
id: id.clone(),
|
||||
name,
|
||||
kind: HostKind::Aws { auth: AwsAuth::Keys { keyring_ref: id }, default_region },
|
||||
};
|
||||
let keys = AwsKeys { access_key_id, secret_access_key, session_token };
|
||||
add_connection_with(
|
||||
conn,
|
||||
Some(keys),
|
||||
real_identity,
|
||||
secure::store_aws_keys,
|
||||
secure::delete_aws_keys,
|
||||
&state.settings_store,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_aws_connection(
|
||||
id: String,
|
||||
name: String,
|
||||
default_region: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HostConnection, AwsError> {
|
||||
let (name, region) = validated(&name, &default_region)?;
|
||||
let (_, updated) = state
|
||||
.settings_store
|
||||
.modify(|settings| {
|
||||
aws_conn(&id, settings)?;
|
||||
let conn = settings.hosts.iter_mut().find(|h| h.id == id).expect("checked above");
|
||||
conn.name = name;
|
||||
let HostKind::Aws { default_region, .. } = &mut conn.kind;
|
||||
*default_region = region;
|
||||
Ok(conn.clone())
|
||||
})
|
||||
.map_err(other)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_host_connection(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
remove_connection_with(&id, secure::delete_aws_keys, &state.settings_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_connection_identity(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CallerIdentity, AwsError> {
|
||||
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
|
||||
let HostKind::Aws { default_region, .. } = &conn.kind;
|
||||
let cfg = sdk_config(&conn, default_region).await?;
|
||||
caller_identity(&cfg).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_connection_sso_login(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let conn = aws_conn(&id, &state.settings_store.get())?;
|
||||
let HostKind::Aws { auth, .. } = &conn.kind;
|
||||
let AwsAuth::Profile { name } = auth else {
|
||||
return Err("Only profile connections use SSO".to_string());
|
||||
};
|
||||
let name = validate_profile_name(name)?;
|
||||
super::aws_commands::run_sso_login(&name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_aws_permissions(
|
||||
id: String,
|
||||
region: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PermissionReport, AwsError> {
|
||||
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
|
||||
let HostKind::Aws { default_region, .. } = &conn.kind;
|
||||
let region = match region {
|
||||
Some(r) => validate_supported_region(&r).map_err(other)?,
|
||||
None => default_region.clone(),
|
||||
};
|
||||
let cfg = sdk_config(&conn, ®ion).await?;
|
||||
let identity = caller_identity(&cfg).await?;
|
||||
let api = SdkPermissionApi::new(&cfg);
|
||||
check_permissions(&api, &identity, ®ion).await
|
||||
}
|
||||
|
||||
/// One policy file of a tier: its file name under `aws/policies/` and its
|
||||
/// JSON, verbatim.
|
||||
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
|
||||
pub struct PolicyFile {
|
||||
pub name: String,
|
||||
pub json: String,
|
||||
}
|
||||
|
||||
/// Every policy file `tier` needs, in order; all of them are required (D7).
|
||||
#[tauri::command]
|
||||
pub fn aws_policy_document(tier: Tier) -> Vec<PolicyFile> {
|
||||
policy_files(tier)
|
||||
.iter()
|
||||
.map(|(name, json)| PolicyFile {
|
||||
name: name.to_string(),
|
||||
json: json.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn profile_conn() -> HostConnection {
|
||||
HostConnection {
|
||||
id: "c1".into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "dev".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn keys_conn() -> HostConnection {
|
||||
HostConnection {
|
||||
id: "c2".into(),
|
||||
name: "K".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn keys() -> AwsKeys {
|
||||
AwsKeys { access_key_id: "AKIA".into(), secret_access_key: "s".into(), session_token: None }
|
||||
}
|
||||
|
||||
fn ident() -> CallerIdentity {
|
||||
CallerIdentity { account: "1".into(), arn: "arn:aws:iam::1:user/u".into(), user_id: "U".into() }
|
||||
}
|
||||
|
||||
fn store_in(dir: &tempfile::TempDir) -> SettingsStore {
|
||||
SettingsStore::at_path(dir.path().join("settings.json"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adding_saves_nothing_when_identity_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let calls = RefCell::new(Vec::<String>::new());
|
||||
let err = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|_| async { Err(AwsError::InvalidCredentials { message: "bad".into() }) },
|
||||
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|
||||
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::InvalidCredentials { .. }));
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
assert!(calls.borrow().is_empty(), "{:?}", calls.borrow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_sso_expired_profile_is_still_saved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let saved = add_connection_with(
|
||||
profile_conn(),
|
||||
None,
|
||||
|_| async { Err(AwsError::SsoExpired { profile: "dev".into() }) },
|
||||
|_, _| Ok(()),
|
||||
|_| Ok(()),
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.get().hosts, vec![saved]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_successful_keys_add_stores_keys_then_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let calls = RefCell::new(Vec::<String>::new());
|
||||
let saved = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|c| async move {
|
||||
assert!(matches!(c, Credentials::Keys(..)));
|
||||
Ok(ident())
|
||||
},
|
||||
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|
||||
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*calls.borrow(), vec!["store c2".to_string()]);
|
||||
assert_eq!(settings.get().hosts, vec![saved]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keys_are_removed_if_saving_settings_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// The parent directory does not exist, so saving fails.
|
||||
let settings = SettingsStore::at_path(dir.path().join("missing/settings.json"));
|
||||
let calls = RefCell::new(Vec::<String>::new());
|
||||
let err = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|_| async { Ok(ident()) },
|
||||
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|
||||
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::Other { .. }));
|
||||
assert_eq!(*calls.borrow(), vec!["store c2".to_string(), "delete c2".to_string()]);
|
||||
assert!(settings.get().hosts.is_empty(), "no unsaved connection in memory");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_sso_identity_error_on_a_profile_saves_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let err = add_connection_with(
|
||||
profile_conn(),
|
||||
None,
|
||||
|_| async { Err(AwsError::ProfileNotFound { profile: "dev".into() }) },
|
||||
|_, _| Ok(()),
|
||||
|_| Ok(()),
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::ProfileNotFound { .. }));
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_store_keys_saves_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let deletes = RefCell::new(0);
|
||||
let err = add_connection_with(
|
||||
keys_conn(),
|
||||
Some(keys()),
|
||||
|_| async { Ok(ident()) },
|
||||
|_, _| Err("keychain locked".to_string()),
|
||||
|_| { *deletes.borrow_mut() += 1; Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::Other { .. }));
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keys_connection_whose_keyring_ref_differs_from_its_id_is_refused() {
|
||||
let mut s = AppSettings::default();
|
||||
let mut c = keys_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "other".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
s.hosts.push(c);
|
||||
assert!(aws_conn("c2", &s).is_err());
|
||||
s.hosts = vec![keys_conn()];
|
||||
assert!(aws_conn("c2", &s).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_regions_and_profiles_are_revalidated() {
|
||||
let with = |profile: &str, region: &str| {
|
||||
let mut c = profile_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: profile.into() },
|
||||
default_region: region.into(),
|
||||
};
|
||||
let mut s = AppSettings::default();
|
||||
s.hosts.push(c);
|
||||
s
|
||||
};
|
||||
assert!(aws_conn("c1", &with("dev", "us-west-2")).is_ok());
|
||||
assert!(aws_conn("c1", &with("dev", "not a region")).is_err());
|
||||
assert!(aws_conn("c1", &with("a b", "us-west-2")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_with_an_invalid_stored_region_can_still_be_removed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let mut c = keys_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
|
||||
default_region: "not a region".into(),
|
||||
};
|
||||
settings.update(AppSettings { hosts: vec![c, profile_conn()], ..AppSettings::default() }).unwrap();
|
||||
let deleted = RefCell::new(Vec::<String>::new());
|
||||
let after = remove_connection_with(
|
||||
"c2",
|
||||
|id| { deleted.borrow_mut().push(id.to_string()); Ok(()) },
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(after.hosts, vec![profile_conn()]);
|
||||
assert_eq!(settings.get().hosts, vec![profile_conn()]);
|
||||
assert_eq!(*deleted.borrow(), vec!["c2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_leaves_the_keychain_alone_when_keyring_ref_is_not_the_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = store_in(&dir);
|
||||
let mut c = keys_conn();
|
||||
c.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
settings.update(AppSettings { hosts: vec![c], ..AppSettings::default() }).unwrap();
|
||||
remove_connection_with("c2", |_| panic!("must not touch the keychain"), &settings).unwrap();
|
||||
assert!(settings.get().hosts.is_empty());
|
||||
assert!(remove_connection_with("c2", |_| Ok(()), &settings).is_err());
|
||||
assert!(remove_connection_with("bad id!", |_| Ok(()), &settings).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn china_and_govcloud_regions_are_refused_for_connections() {
|
||||
for r in ["cn-north-1", "us-gov-west-1"] {
|
||||
let e = validated("Prod", r).unwrap_err();
|
||||
assert!(matches!(&e, AwsError::Other { message } if message == "China and GovCloud regions aren't supported yet."), "{e:?}");
|
||||
}
|
||||
assert!(validated("Prod", "eu-west-1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_policy_document_is_every_file_of_the_tier() {
|
||||
let admin = aws_policy_document(Tier::EnvironmentAdmin);
|
||||
let names: Vec<&str> = admin.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["environment-admin.json", "environment-admin-network.json"]);
|
||||
for f in &admin {
|
||||
serde_json::from_str::<serde_json::Value>(&f.json).expect("policy JSON");
|
||||
}
|
||||
let ws = aws_policy_document(Tier::WorkspaceUser);
|
||||
assert_eq!(ws.len(), 1);
|
||||
assert_eq!(ws[0].name, "workspace-user.json");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&ws[0]).unwrap()["name"],
|
||||
"workspace-user.json"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@
|
||||
//!
|
||||
//! ## Container access
|
||||
//!
|
||||
//! All work happens inside the container via the existing `docker exec`
|
||||
//! plumbing in [`crate::docker::exec`] — no second mechanism is introduced.
|
||||
//! All work happens inside the container via the existing exec plumbing,
|
||||
//! reached through [`crate::host::ContainerTarget`] — no second mechanism is
|
||||
//! introduced.
|
||||
//! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with
|
||||
//! `jq`) runs *in* the container and only a small JSON summary crosses the
|
||||
//! wire, so multi-megabyte transcripts are never streamed back.
|
||||
@@ -56,14 +57,11 @@
|
||||
//! commands fail loudly, since they cannot do anything useful without a
|
||||
//! running container.
|
||||
|
||||
use bollard::exec::{CreateExecOptions, StartExecOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status};
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
use crate::host::{ContainerTarget, ExecSpec};
|
||||
use crate::AppState;
|
||||
|
||||
/// Newest N session transcripts to inspect. Caps the work done inside the
|
||||
@@ -192,7 +190,7 @@ pub struct SchedulerNotification {
|
||||
// Shared helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a project to a *running* container id.
|
||||
/// Resolve a project to its *running* container.
|
||||
///
|
||||
/// `Ok(None)` means "there is nothing to inspect" — no container recorded, or
|
||||
/// the container exists but is stopped. Callers that are read-only turn that
|
||||
@@ -201,19 +199,19 @@ pub struct SchedulerNotification {
|
||||
async fn running_container_for(
|
||||
project_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<Option<ContainerTarget>, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = match project.container_id {
|
||||
Some(id) => id,
|
||||
let target = match ContainerTarget::for_project(&project) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if is_container_running(&container_id).await.unwrap_or(false) {
|
||||
Ok(Some(container_id))
|
||||
if target.is_running().await.unwrap_or(false) {
|
||||
Ok(Some(target))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -224,7 +222,7 @@ async fn running_container_for(
|
||||
async fn require_running_container(
|
||||
project_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
running_container_for(project_id, state).await?.ok_or_else(|| {
|
||||
"Container is not running — start the project first.".to_string()
|
||||
})
|
||||
@@ -240,13 +238,13 @@ fn home_env() -> Vec<String> {
|
||||
/// the first line) so the combined stream `exec_oneshot_env` returns is pure
|
||||
/// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as
|
||||
/// empty output, which the callers treat as "nothing to report".
|
||||
async fn run_script(container_id: &str, script: impl Into<String>) -> Result<String, String> {
|
||||
exec_oneshot_env(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script.into()],
|
||||
home_env(),
|
||||
)
|
||||
.await
|
||||
async fn run_script(target: &ContainerTarget, script: impl Into<String>) -> Result<String, String> {
|
||||
target
|
||||
.exec_oneshot_env(
|
||||
vec!["sh".to_string(), "-c".to_string(), script.into()],
|
||||
home_env(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Parse script output as JSON, degrading to a default value (and a log line)
|
||||
@@ -315,11 +313,11 @@ pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
|
||||
/// Run `triple-c-scheduler <args…>` as a bare argv vector — no shell is
|
||||
/// involved, so caller-supplied ids cannot be interpreted as shell syntax.
|
||||
/// Returns the combined output, erroring with it on a non-zero exit.
|
||||
async fn run_scheduler(container_id: &str, args: Vec<String>) -> Result<String, String> {
|
||||
async fn run_scheduler(target: &ContainerTarget, args: Vec<String>) -> Result<String, String> {
|
||||
let mut cmd = vec!["triple-c-scheduler".to_string()];
|
||||
cmd.extend(args);
|
||||
|
||||
let (output, exit_code) = exec_oneshot_env_status(container_id, cmd, home_env()).await?;
|
||||
let (output, exit_code) = target.exec_oneshot_env_status(cmd, home_env()).await?;
|
||||
if exit_code != 0 {
|
||||
let detail = output.trim();
|
||||
return Err(if detail.is_empty() {
|
||||
@@ -395,8 +393,8 @@ pub async fn list_claude_sessions(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ClaudeSession>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
@@ -404,7 +402,7 @@ pub async fn list_claude_sessions(
|
||||
// a const usize — never from the caller.
|
||||
let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string());
|
||||
|
||||
let raw = run_script(&container_id, script).await?;
|
||||
let raw = run_script(&target, script).await?;
|
||||
let sessions: Vec<RawSession> = parse_or_default(&raw, "session list");
|
||||
|
||||
Ok(sessions
|
||||
@@ -566,12 +564,12 @@ pub async fn list_container_capabilities(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ContainerCapabilities, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(ContainerCapabilities::default()),
|
||||
};
|
||||
|
||||
let raw = run_script(&container_id, CAPABILITIES_SCRIPT).await?;
|
||||
let raw = run_script(&target, CAPABILITIES_SCRIPT).await?;
|
||||
Ok(parse_or_default(&raw, "container capabilities"))
|
||||
}
|
||||
|
||||
@@ -681,12 +679,12 @@ pub async fn list_scheduled_tasks(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ScheduledTask>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let raw = run_script(&container_id, SCHEDULER_LIST_SCRIPT).await?;
|
||||
let raw = run_script(&target, SCHEDULER_LIST_SCRIPT).await?;
|
||||
let tasks: Vec<RawScheduledTask> = parse_or_default(&raw, "scheduled tasks");
|
||||
|
||||
Ok(tasks
|
||||
@@ -731,13 +729,13 @@ pub async fn get_scheduled_task_log(
|
||||
// Clamped, and an integer by type — cannot carry shell syntax.
|
||||
let tail = tail_lines.unwrap_or(200).clamp(1, 5000);
|
||||
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(String::new()),
|
||||
};
|
||||
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec![
|
||||
"logs".to_string(),
|
||||
"--id".to_string(),
|
||||
@@ -756,15 +754,15 @@ pub async fn get_scheduler_notifications(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SchedulerNotification>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let script =
|
||||
SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string());
|
||||
|
||||
let raw = run_script(&container_id, script).await?;
|
||||
let raw = run_script(&target, script).await?;
|
||||
let notifications: Vec<RawNotification> = parse_or_default(&raw, "scheduler notifications");
|
||||
|
||||
Ok(notifications
|
||||
@@ -1140,9 +1138,9 @@ pub async fn add_scheduled_task(
|
||||
&schedule,
|
||||
working_dir.as_deref(),
|
||||
)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(&container_id, input.add_args()).await?;
|
||||
let output = run_scheduler(&target, input.add_args()).await?;
|
||||
let task_id = parse_created_task_id(&output).ok_or_else(|| {
|
||||
format!(
|
||||
"The scheduler did not report a task id. Its output was: {}",
|
||||
@@ -1200,9 +1198,9 @@ pub async fn update_scheduled_task(
|
||||
&schedule,
|
||||
working_dir.as_deref(),
|
||||
)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(&container_id, input.add_args()).await?;
|
||||
let output = run_scheduler(&target, input.add_args()).await?;
|
||||
let new_id = parse_created_task_id(&output).ok_or_else(|| {
|
||||
format!(
|
||||
"The scheduler did not report a task id, so the original task was left in place. Its output was: {}",
|
||||
@@ -1211,7 +1209,7 @@ pub async fn update_scheduled_task(
|
||||
})?;
|
||||
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["remove".to_string(), "--id".to_string(), task_id.clone()],
|
||||
)
|
||||
.await
|
||||
@@ -1224,7 +1222,7 @@ pub async fn update_scheduled_task(
|
||||
|
||||
if enabled == Some(false) {
|
||||
if let Err(e) = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["disable".to_string(), "--id".to_string(), new_id.clone()],
|
||||
)
|
||||
.await
|
||||
@@ -1253,11 +1251,11 @@ pub async fn set_scheduled_task_enabled(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let subcommand = if enabled { "enable" } else { "disable" };
|
||||
let output = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec![subcommand.to_string(), "--id".to_string(), task_id],
|
||||
)
|
||||
.await?;
|
||||
@@ -1277,39 +1275,20 @@ pub async fn run_scheduled_task_now(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
&container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(false),
|
||||
attach_stderr: Some(false),
|
||||
// Argv vector — no shell, so `task_id` is inert as data.
|
||||
cmd: Some(vec![
|
||||
"triple-c-scheduler".to_string(),
|
||||
"run".to_string(),
|
||||
"--id".to_string(),
|
||||
task_id.clone(),
|
||||
]),
|
||||
env: Some(home_env()),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
|
||||
docker
|
||||
.start_exec(
|
||||
&exec.id,
|
||||
Some(StartExecOptions {
|
||||
detach: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
// Argv vector — no shell, so `task_id` is inert as data. Stdin, stdout and
|
||||
// stderr are all left unattached; the exec is started detached.
|
||||
let spec = ExecSpec::new(vec![
|
||||
"triple-c-scheduler".to_string(),
|
||||
"run".to_string(),
|
||||
"--id".to_string(),
|
||||
task_id.clone(),
|
||||
])
|
||||
.env(home_env())
|
||||
.no_stdin();
|
||||
let exec_id = target
|
||||
.exec_detached(spec)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start task: {}", e))?;
|
||||
|
||||
@@ -1317,7 +1296,7 @@ pub async fn run_scheduled_task_now(
|
||||
"Triggered scheduler task {} in project {} (detached exec {})",
|
||||
task_id,
|
||||
project_id,
|
||||
exec.id
|
||||
exec_id
|
||||
);
|
||||
Ok(format!("Task {} started.", task_id))
|
||||
}
|
||||
@@ -1330,10 +1309,10 @@ pub async fn remove_scheduled_task(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["remove".to_string(), "--id".to_string(), task_id],
|
||||
)
|
||||
.await?;
|
||||
@@ -1346,9 +1325,9 @@ pub async fn clear_scheduler_notifications(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["notifications".to_string(), "--clear".to_string()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashSet};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager};
|
||||
use crate::models::marketplace::{
|
||||
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate,
|
||||
@@ -173,10 +173,7 @@ pub(crate) mod ops {
|
||||
/// directories, so an id from outside (an import) must be the shape the
|
||||
/// commands mint: a UUID-like `[A-Za-z0-9-]{1,64}`.
|
||||
pub fn validate_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
|
||||
if ok {
|
||||
if crate::storage::secure::is_valid_connection_id(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("An account or marketplace id is malformed.".to_string())
|
||||
@@ -944,11 +941,11 @@ pub async fn apply_marketplace_now(
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
for p in projects {
|
||||
let running = match &p.container_id {
|
||||
Some(cid) => is_container_running(cid).await.unwrap_or(false),
|
||||
None => false,
|
||||
let running = match ContainerTarget::for_project(&p) {
|
||||
Some(target) if target.is_running().await.unwrap_or(false) => Some(target),
|
||||
_ => None,
|
||||
};
|
||||
if !running {
|
||||
let Some(target) = running else {
|
||||
if project_id.is_some() {
|
||||
return Err(format!(
|
||||
"\"{}\" is not running. Its marketplace items are applied when it starts.",
|
||||
@@ -956,9 +953,8 @@ pub async fn apply_marketplace_now(
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let cid = p.container_id.clone().unwrap_or_default();
|
||||
let report = mk::sync_project(&state.marketplace, &settings, &p, &cid).await;
|
||||
};
|
||||
let report = mk::sync_project(&state.marketplace, &settings, &p, &target).await;
|
||||
let _ = app_handle.emit(
|
||||
mk::SYNC_FINISHED_EVENT,
|
||||
serde_json::json!({ "project_id": p.id, "report": report }),
|
||||
@@ -1051,13 +1047,13 @@ pub async fn start_marketplace_gh_container_login(
|
||||
return Err("Signing in through a container needs a host name without a port.".to_string());
|
||||
}
|
||||
let project = find_project(&state, &project_id)?;
|
||||
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||
let target = ContainerTarget::for_project(&project).ok_or_else(|| {
|
||||
format!(
|
||||
"\"{}\" has no container yet. Start it, then try again.",
|
||||
project.name
|
||||
)
|
||||
})?;
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !target.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"\"{}\" is not running. Start it, then try again.",
|
||||
project.name
|
||||
@@ -1070,7 +1066,7 @@ pub async fn start_marketplace_gh_container_login(
|
||||
}
|
||||
let account_id = uuid::Uuid::new_v4().to_string();
|
||||
let result =
|
||||
gh_login::run_gh_container_login(&app_handle, &account_id, &container_id, &host, rx).await;
|
||||
gh_login::run_gh_container_login(&app_handle, &account_id, &target, &host, rx).await;
|
||||
// `rx` is gone now, so this frees our slot and never a newer login's.
|
||||
state.marketplace.release_gh_login().await;
|
||||
let token = result?;
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::project_commands::{create_container_for_project, emit_progress};
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::{create_container_for_project, default_docker_socket};
|
||||
use crate::docker;
|
||||
use crate::docker::migration::{self as mig, Recovery};
|
||||
use crate::models::{
|
||||
@@ -1810,14 +1811,6 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
migration_store::clear_ownerless_for_project(project_id);
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn human_bytes(n: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut v = n as f64;
|
||||
@@ -1860,7 +1853,7 @@ async fn stage_payload(
|
||||
project_id: &str,
|
||||
verbatim: &[String],
|
||||
) -> Result<String, String> {
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::ExecChunk;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
@@ -1913,14 +1906,14 @@ async fn stage_payload(
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write the staging file: {}", e));
|
||||
break;
|
||||
}
|
||||
total += message.len() as u64;
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -1979,7 +1972,7 @@ async fn stage_payload(
|
||||
/// anything already present in the base, so collisions should be rare either
|
||||
/// way.)
|
||||
async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), String> {
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::ExecChunk;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -2007,7 +2000,7 @@ async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), Stri
|
||||
let mut text = String::new();
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdErr { message }) | Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) | Ok(ExecChunk::StdOut(message)) => {
|
||||
text.push_str(&String::from_utf8_lossy(&message))
|
||||
}
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -2,10 +2,12 @@ pub mod auth_bridge_commands;
|
||||
pub mod auth_token_commands;
|
||||
pub mod aws_commands;
|
||||
pub mod docker_commands;
|
||||
pub mod environment_commands;
|
||||
pub mod file_commands;
|
||||
pub mod file_viewer_commands;
|
||||
pub mod gateway_commands;
|
||||
pub mod help_commands;
|
||||
pub mod host_commands;
|
||||
pub mod inspect_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod marketplace_commands;
|
||||
|
||||
@@ -2,7 +2,9 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::host::lifecycle::{DiscardLeftovers, ProjectHost};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -114,48 +116,6 @@ fn store_secrets_for_project(project: &Project, explicitly_cleared: &[&str]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the two ordinary create paths below and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub(crate) async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: docker::CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
docker::create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Populate secret fields on a project struct from the OS keychain.
|
||||
pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
||||
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||
@@ -774,7 +734,8 @@ pub async fn remove_project(
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = match docker::find_existing_container(project).await {
|
||||
let host = ProjectHost::for_project(project);
|
||||
let container_ref = match host.find_current(project).await {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
@@ -785,15 +746,14 @@ pub async fn remove_project(
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
if let Some(ref container) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container.id()) {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
if let Err(e) = docker::remove_container(container_id).await {
|
||||
if let Err(e) = host.remove(container).await {
|
||||
log::warn!(
|
||||
"Failed to remove container {} for project {}: {}",
|
||||
container_id, project_id, e
|
||||
container.id(), project_id, e
|
||||
);
|
||||
// Recorded by name, not id: the name is the stable handle a
|
||||
// later retry can still resolve (Docker's remove-container
|
||||
@@ -805,15 +765,12 @@ pub async fn remove_project(
|
||||
|
||||
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||
// containers first, then the per-project network they were attached to.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
host.remove_legacy_leftovers(project).await;
|
||||
|
||||
// Clean up the snapshot image + volumes
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
report.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
report.volumes = docker::remove_project_volumes(project).await;
|
||||
let left = host.discard_project_state(project).await;
|
||||
report.image = left.image;
|
||||
report.volumes = left.volumes;
|
||||
}
|
||||
|
||||
// Clean up keychain secrets for this project
|
||||
@@ -1204,9 +1161,9 @@ async fn start_project_container_locked(
|
||||
// in memory when building environment variables for the container.
|
||||
load_secrets_for_project(&mut project);
|
||||
|
||||
// Load settings for image resolution and global AWS
|
||||
// Load settings: backend validation below, and the host's start (image
|
||||
// resolution, global AWS, …).
|
||||
let settings = state.settings_store.get();
|
||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Validate backend requirements
|
||||
if project.backend == Backend::Bedrock {
|
||||
@@ -1322,140 +1279,18 @@ async fn start_project_container_locked(
|
||||
}
|
||||
|
||||
// Wrap container operations so that any failure resets status to Stopped.
|
||||
let result: Result<String, String> = async {
|
||||
// Ensure image exists
|
||||
emit_progress(&app_handle, &project_id, "Checking image...");
|
||||
if !docker::image_exists(&image_name).await? {
|
||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||
}
|
||||
|
||||
// Determine docker socket path
|
||||
let docker_socket = settings.docker_socket_path
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_docker_socket());
|
||||
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
&project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||
// Snapshot the filesystem before destroying
|
||||
emit_progress(&app_handle, &project_id, "Saving container state...");
|
||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||
}
|
||||
emit_progress(&app_handle, &project_id, "Recreating container...");
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Legacy MCP cleanup: the old container may have been attached to
|
||||
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||
// that network now, before the replacement is created without it.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
// The commit above moved `:latest` and orphaned the image it
|
||||
// used to point at; the container holding that image open was
|
||||
// removed a few lines up, so now is when Docker will actually
|
||||
// let it go. Detached because this is housekeeping and the
|
||||
// project is already running — and it sweeps every orphan, not
|
||||
// just this one, so recreations that happened before the sweep
|
||||
// existed are cleaned up too.
|
||||
tauri::async_runtime::spawn(async {
|
||||
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
||||
});
|
||||
|
||||
new_id
|
||||
} else {
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
}
|
||||
} else {
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let result: Result<ContainerTarget, String> = async {
|
||||
let target = host
|
||||
.ensure_started(&project, &settings, &|msg: &str| {
|
||||
emit_progress(&app_handle, &project_id, msg)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Sync Bedrock credentials on every start: refresh static/session creds
|
||||
// so rotated keys are picked up without a full container recreation, and
|
||||
// clear stale creds when the project no longer uses static-cred Bedrock.
|
||||
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
|
||||
if let Err(e) = docker::sync_bedrock_credentials(&target, &project).await {
|
||||
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
||||
}
|
||||
|
||||
@@ -1466,10 +1301,10 @@ async fn start_project_container_locked(
|
||||
state.marketplace.clone(),
|
||||
state.settings_store.get(),
|
||||
project.clone(),
|
||||
container_id.clone(),
|
||||
target.clone(),
|
||||
);
|
||||
|
||||
Ok(container_id)
|
||||
Ok(target)
|
||||
}.await;
|
||||
|
||||
// On failure, reset status to Stopped so the project doesn't get stuck.
|
||||
@@ -1478,7 +1313,8 @@ async fn start_project_container_locked(
|
||||
let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped);
|
||||
}
|
||||
|
||||
let container_id = result?;
|
||||
let target = result?;
|
||||
let container_id = target.id().to_string();
|
||||
|
||||
// Update project with container info using granular methods (Issue 14: TOCTOU)
|
||||
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||
@@ -1491,7 +1327,7 @@ async fn start_project_container_locked(
|
||||
.auth_bridge
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id.clone(),
|
||||
target.clone(),
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -1527,13 +1363,13 @@ pub async fn stop_project_container(
|
||||
// Drop host listeners first: they only make sense while the container runs.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
// Close exec sessions for this project
|
||||
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
|
||||
if let Err(e) = docker::stop_container(container_id).await {
|
||||
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
|
||||
if let Err(e) = ProjectHost::for_project(&project).stop(&container).await {
|
||||
log::warn!("Stop failed for container {} (project {}): {} — resetting to Stopped anyway", container.id(), project_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1588,13 +1424,13 @@ pub async fn rebuild_project_container(
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = docker::find_existing_container(&project).await?;
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let container_ref = host.find_current(&project).await?;
|
||||
if let Some(ref container) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container.id()) {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await?;
|
||||
host.remove(container).await?;
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
}
|
||||
|
||||
@@ -1607,12 +1443,10 @@ pub async fn rebuild_project_container(
|
||||
// discard. No pending-cleanup record for either: unlike `remove_project`,
|
||||
// Reset keeps the project record, so a later Reset attempt can retry
|
||||
// these itself rather than needing startup housekeeping to do it.
|
||||
let mut leftover_image = None;
|
||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
leftover_image = Some(docker::get_snapshot_image_name(&project));
|
||||
}
|
||||
let leftover_volumes = docker::remove_project_volumes(&project).await;
|
||||
let DiscardLeftovers {
|
||||
image: leftover_image,
|
||||
volumes: leftover_volumes,
|
||||
} = host.discard_project_state(&project).await;
|
||||
if leftover_image.is_some() || !leftover_volumes.is_empty() {
|
||||
log::warn!(
|
||||
"Reset for project {} could not fully clean up — image: {:?}, volumes: {:?} — the \
|
||||
@@ -1679,8 +1513,8 @@ pub async fn reconcile_project_statuses(
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_running = if let Some(ref container_id) = project.container_id {
|
||||
docker::is_container_running(container_id).await.unwrap_or(false)
|
||||
let is_running = if let Some(container) = ContainerTarget::for_project(project) {
|
||||
container.is_running().await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -1695,12 +1529,12 @@ pub async fn reconcile_project_statuses(
|
||||
// bridge lives in this process, so re-arm it here. `start` is
|
||||
// idempotent, so a bridge that is already polling is untouched.
|
||||
if project.auth_bridge_enabled {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
if let Some(target) = crate::host::ContainerTarget::for_project(&project) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
project.id.clone(),
|
||||
container_id.clone(),
|
||||
target,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -1720,13 +1554,6 @@ pub async fn reconcile_project_statuses(
|
||||
Ok(state.projects_store.list())
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -78,6 +78,13 @@ pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &Ap
|
||||
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
|
||||
}
|
||||
|
||||
/// Host connections change only through `host_commands` (which validates
|
||||
/// them and owns their keychain entries) and settings import, never through
|
||||
/// a settings save the frontend posts back.
|
||||
pub(crate) fn restore_host_fields(incoming: &mut AppSettings, stored: &AppSettings) {
|
||||
incoming.hosts = stored.hosts.clone();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
mut settings: AppSettings,
|
||||
@@ -87,6 +94,7 @@ pub async fn update_settings(
|
||||
|
||||
validate_settings_update(&before, &settings)?;
|
||||
restore_marketplace_fields(&mut settings, &before);
|
||||
restore_host_fields(&mut settings, &before);
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
@@ -466,4 +474,27 @@ mod tests {
|
||||
"the edit the save was for still applies"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_settings_cannot_change_hosts() {
|
||||
use crate::models::{AwsAuth, HostConnection, HostKind};
|
||||
let conn = |id: &str, region: &str| HostConnection {
|
||||
id: id.into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "dev".into() },
|
||||
default_region: region.into(),
|
||||
},
|
||||
};
|
||||
let mut stored = AppSettings::default();
|
||||
stored.hosts.push(conn("c1", "us-west-2"));
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.hosts = vec![conn("c1", "not a region"), conn("evil", "us-east-1")];
|
||||
incoming.auto_check_updates = false;
|
||||
|
||||
restore_host_fields(&mut incoming, &stored);
|
||||
|
||||
assert_eq!(incoming.hosts, stored.hosts);
|
||||
assert!(!incoming.auto_check_updates);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ use zeroize::Zeroizing;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
|
||||
use crate::models::{
|
||||
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||
@@ -138,6 +139,7 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec
|
||||
&settings.marketplace_accounts,
|
||||
secure::get_marketplace_token,
|
||||
),
|
||||
aws_connection_keys: exported_aws_connection_keys(&settings.hosts, secure::get_aws_keys),
|
||||
};
|
||||
|
||||
(settings, secrets)
|
||||
@@ -184,6 +186,107 @@ fn restore_marketplace_tokens(
|
||||
warnings
|
||||
}
|
||||
|
||||
/// The stored keys of every `Keys` host connection, by connection id, as the
|
||||
/// `AwsKeys` JSON. A `Profile` connection holds none. A missing or unreadable
|
||||
/// entry is left out, like the other keychain secrets above.
|
||||
fn exported_aws_connection_keys(
|
||||
hosts: &[HostConnection],
|
||||
get: impl Fn(&str) -> Result<Option<secure::AwsKeys>, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
hosts
|
||||
.iter()
|
||||
.filter(|h| matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. }))
|
||||
.filter_map(|h| {
|
||||
let keys = get(&h.id).unwrap_or_default()?;
|
||||
Some((h.id.clone(), serde_json::to_string(&keys).ok()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write each imported set of AWS keys to the keychain, returning a warning
|
||||
/// (naming the connection id, never the value) for each that could not be.
|
||||
fn restore_aws_connection_keys(
|
||||
keys: &BTreeMap<String, String>,
|
||||
mut store: impl FnMut(&str, &secure::AwsKeys) -> Result<(), String>,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
for (id, json) in keys {
|
||||
if json.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(parsed) = serde_json::from_str::<secure::AwsKeys>(json) else {
|
||||
log::warn!("Settings import: the AWS keys of connection {} are malformed", id);
|
||||
warnings.push(format!(
|
||||
"The AWS access keys of connection {} in the file are malformed; enter them again.",
|
||||
id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = store(id, &parsed) {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the AWS keys of connection {}: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
warnings.push(format!(
|
||||
"Could not restore the AWS access keys of connection {} ({}); enter them again.",
|
||||
id, e
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Checks the imported host connections with the rules the connection
|
||||
/// commands use, before anything is written; refuses the import (like
|
||||
/// `validate_imported_marketplace_state`) rather than applying a bad entry.
|
||||
/// A `Keys` connection's `keyring_ref` must be its own (valid) id, and keys
|
||||
/// may only be carried for a `Keys` connection in the file.
|
||||
fn validate_imported_hosts(
|
||||
settings: &mut AppSettings,
|
||||
keys: &BTreeMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
use crate::models::host_connection::{
|
||||
validate_connection_name, validate_profile_name, validate_supported_region,
|
||||
};
|
||||
let wrap = |e: String| format!("The file's host connections were refused: {e}");
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for h in &mut settings.hosts {
|
||||
if !secure::is_valid_connection_id(&h.id) {
|
||||
return Err(wrap("a connection has an invalid id.".into()));
|
||||
}
|
||||
if !seen.insert(h.id.clone()) {
|
||||
return Err(wrap("a connection appears twice.".into()));
|
||||
}
|
||||
h.name = validate_connection_name(&h.name).map_err(wrap)?;
|
||||
let HostKind::Aws { auth, default_region } = &h.kind;
|
||||
validate_supported_region(default_region).map_err(wrap)?;
|
||||
match auth {
|
||||
AwsAuth::Profile { name } => {
|
||||
validate_profile_name(name).map_err(wrap)?;
|
||||
}
|
||||
AwsAuth::Keys { keyring_ref } => {
|
||||
if !secure::is_valid_connection_id(keyring_ref) || *keyring_ref != h.id {
|
||||
return Err(wrap(format!(
|
||||
"the connection \"{}\" has an invalid key reference.",
|
||||
h.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in keys.keys() {
|
||||
let is_keys_connection = settings.hosts.iter().any(|h| {
|
||||
h.id == *id && matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. })
|
||||
});
|
||||
if !is_keys_connection {
|
||||
return Err(wrap("access keys are present for an unknown connection.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export the current global settings and secrets to a password-encrypted
|
||||
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||
/// deliberately distinguishable from one so the frontend shows nothing
|
||||
@@ -260,7 +363,8 @@ pub async fn preview_settings_import(
|
||||
|
||||
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let local_hosts = state.settings_store.get().hosts;
|
||||
let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_state);
|
||||
|
||||
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||
path,
|
||||
@@ -376,6 +480,9 @@ pub async fn apply_settings_import(
|
||||
&marketplace_tokens,
|
||||
)?;
|
||||
|
||||
let aws_keys = payload.secrets.aws_connection_keys;
|
||||
validate_imported_hosts(&mut settings, &aws_keys)?;
|
||||
|
||||
let mut secret_restore_warnings = Vec::new();
|
||||
let mut gateway_secret_changed = false;
|
||||
|
||||
@@ -423,24 +530,20 @@ pub async fn apply_settings_import(
|
||||
secure::store_marketplace_token,
|
||||
));
|
||||
|
||||
let imported_marketplace = (
|
||||
settings.marketplace_accounts.clone(),
|
||||
settings.marketplaces.clone(),
|
||||
settings.global_marketplace_installs.clone(),
|
||||
);
|
||||
secret_restore_warnings.extend(restore_aws_connection_keys(
|
||||
&aws_keys,
|
||||
secure::store_aws_keys,
|
||||
));
|
||||
|
||||
let imported_owned = StoreOwned::of(&settings);
|
||||
let saved =
|
||||
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||
// `update_settings` keeps marketplace state store-owned. An import is the
|
||||
// one caller entitled to replace it wholesale.
|
||||
let saved = {
|
||||
let mut s = saved;
|
||||
(
|
||||
s.marketplace_accounts,
|
||||
s.marketplaces,
|
||||
s.global_marketplace_installs,
|
||||
) = imported_marketplace;
|
||||
state.settings_store.update(s)?
|
||||
};
|
||||
// `update_settings` keeps marketplace state and host connections
|
||||
// store-owned. An import is the one caller entitled to replace them
|
||||
// wholesale.
|
||||
let saved = state
|
||||
.settings_store
|
||||
.update(imported_owned.applied_to(saved))?;
|
||||
// Caches of marketplaces the import dropped are dead weight now, and
|
||||
// the pins must match the imported installs.
|
||||
use crate::commands::marketplace_commands as mc;
|
||||
@@ -481,6 +584,34 @@ pub async fn apply_settings_import(
|
||||
})
|
||||
}
|
||||
|
||||
/// The settings `update_settings` keeps store-owned (marketplace state and
|
||||
/// host connections), which an import re-applies after that call.
|
||||
struct StoreOwned {
|
||||
marketplace_accounts: Vec<crate::models::marketplace::MarketplaceAccount>,
|
||||
marketplaces: Vec<crate::models::marketplace::Marketplace>,
|
||||
global_marketplace_installs: Vec<crate::models::marketplace::MarketplaceInstall>,
|
||||
hosts: Vec<crate::models::HostConnection>,
|
||||
}
|
||||
|
||||
impl StoreOwned {
|
||||
fn of(s: &AppSettings) -> Self {
|
||||
Self {
|
||||
marketplace_accounts: s.marketplace_accounts.clone(),
|
||||
marketplaces: s.marketplaces.clone(),
|
||||
global_marketplace_installs: s.global_marketplace_installs.clone(),
|
||||
hosts: s.hosts.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_to(self, mut s: AppSettings) -> AppSettings {
|
||||
s.marketplace_accounts = self.marketplace_accounts;
|
||||
s.marketplaces = self.marketplaces;
|
||||
s.global_marketplace_installs = self.global_marketplace_installs;
|
||||
s.hosts = self.hosts;
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<String>) -> Option<String> {
|
||||
value.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
@@ -827,4 +958,141 @@ mod tests {
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(!warnings[0].contains("test-token-not-real"));
|
||||
}
|
||||
|
||||
fn keys_host(id: &str) -> HostConnection {
|
||||
HostConnection {
|
||||
id: id.into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: id.into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_host(id: &str) -> HostConnection {
|
||||
HostConnection {
|
||||
id: id.into(),
|
||||
name: "Dev".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn test_keys() -> secure::AwsKeys {
|
||||
secure::AwsKeys {
|
||||
access_key_id: "AKIATESTNOTREAL".into(),
|
||||
secret_access_key: "test-secret-not-real".into(),
|
||||
session_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_connection_keys_round_trip_through_export() {
|
||||
let hosts = vec![keys_host("k1"), profile_host("p1")];
|
||||
let exported = exported_aws_connection_keys(&hosts, |id| match id {
|
||||
"k1" => Ok(Some(test_keys())),
|
||||
other => panic!("only a Keys connection is read, got {other}"),
|
||||
});
|
||||
assert_eq!(exported.keys().collect::<Vec<_>>(), vec!["k1"]);
|
||||
assert_eq!(serde_json::from_str::<secure::AwsKeys>(&exported["k1"]).unwrap(), test_keys());
|
||||
|
||||
let mut stored = Vec::new();
|
||||
let warnings = restore_aws_connection_keys(&exported, |id, keys| {
|
||||
stored.push((id.to_string(), keys.clone()));
|
||||
Ok(())
|
||||
});
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(stored, vec![("k1".to_string(), test_keys())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_connection_keys_restore_failures_name_the_id_not_the_value() {
|
||||
let keys = BTreeMap::from([
|
||||
("bad".to_string(), "not json test-secret-not-real".to_string()),
|
||||
("locked".to_string(), serde_json::to_string(&test_keys()).unwrap()),
|
||||
]);
|
||||
let warnings = restore_aws_connection_keys(&keys, |_, _| Err("keychain locked".into()));
|
||||
assert_eq!(warnings.len(), 2);
|
||||
assert!(warnings[0].contains("bad"));
|
||||
assert!(warnings[1].contains("locked"));
|
||||
assert!(warnings.iter().all(|w| !w.contains("test-secret-not-real")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_hosts_are_validated() {
|
||||
let check = |hosts: Vec<HostConnection>, keys: BTreeMap<String, String>| {
|
||||
let mut s = AppSettings::default();
|
||||
s.hosts = hosts;
|
||||
validate_imported_hosts(&mut s, &keys)
|
||||
};
|
||||
let none = BTreeMap::new();
|
||||
assert!(check(vec![keys_host("k1"), profile_host("p1")], none.clone()).is_ok());
|
||||
assert!(check(vec![keys_host("k1")], BTreeMap::from([("k1".into(), "{}".into())])).is_ok());
|
||||
|
||||
let mut bad_name = profile_host("p1");
|
||||
bad_name.name = " ".into();
|
||||
assert!(check(vec![bad_name], none.clone()).is_err());
|
||||
|
||||
let mut bad_region = profile_host("p1");
|
||||
bad_region.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "nowhere".into(),
|
||||
};
|
||||
assert!(check(vec![bad_region], none.clone()).is_err());
|
||||
|
||||
let mut cn = profile_host("p1");
|
||||
cn.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "cn-north-1".into(),
|
||||
};
|
||||
let err = check(vec![cn], none.clone()).unwrap_err();
|
||||
assert!(err.contains("China and GovCloud regions aren't supported yet."), "{err}");
|
||||
|
||||
let mut bad_profile = profile_host("p1");
|
||||
bad_profile.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "a b".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
assert!(check(vec![bad_profile], none.clone()).is_err());
|
||||
|
||||
let mut other_ref = keys_host("k1");
|
||||
other_ref.kind = HostKind::Aws {
|
||||
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
};
|
||||
assert!(check(vec![other_ref], none.clone()).is_err());
|
||||
|
||||
assert!(check(vec![profile_host("../x")], none.clone()).is_err());
|
||||
assert!(check(vec![profile_host("p1"), profile_host("p1")], none.clone()).is_err());
|
||||
// Keys carried for a connection that is not a Keys connection.
|
||||
assert!(check(vec![profile_host("p1")], BTreeMap::from([("p1".into(), "{}".into())])).is_err());
|
||||
assert!(check(vec![], BTreeMap::from([("ghost".into(), "{}".into())])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_hosts_survive_apply() {
|
||||
use crate::models::{AwsAuth, HostConnection, HostKind};
|
||||
let host = HostConnection {
|
||||
id: "h1".into(),
|
||||
name: "Imported".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "dev".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
};
|
||||
let stored = AppSettings::default();
|
||||
let mut imported = AppSettings::default();
|
||||
imported.hosts.push(host.clone());
|
||||
|
||||
let owned = StoreOwned::of(&imported);
|
||||
// What `update_settings` does to the incoming settings.
|
||||
let mut after_update = imported.clone();
|
||||
crate::commands::settings_commands::restore_host_fields(&mut after_update, &stored);
|
||||
assert!(after_update.hosts.is_empty(), "update_settings drops hosts");
|
||||
|
||||
assert_eq!(owned.applied_to(after_update).hosts, vec![host]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||
use crate::AppState;
|
||||
|
||||
@@ -159,9 +160,7 @@ pub async fn open_terminal_session(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
if let Some(id) = resume_session_id.as_deref() {
|
||||
@@ -189,7 +188,7 @@ pub async fn open_terminal_session(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(
|
||||
container_id,
|
||||
&target,
|
||||
&session_id,
|
||||
cmd,
|
||||
move |data| {
|
||||
@@ -240,7 +239,7 @@ pub async fn paste_image_to_terminal(
|
||||
image_data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -250,7 +249,7 @@ pub async fn paste_image_to_terminal(
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||
.write_file_to_container(&target, &file_name, &image_data)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -283,7 +282,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
let meta = tokio::fs::metadata(&host_path)
|
||||
.await
|
||||
@@ -313,7 +312,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
// with the code that does the reading, which re-applies it to the open
|
||||
// descriptor — this check is here only so the refusal reads like a sentence
|
||||
// instead of arriving after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
use crate::host::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
@@ -326,20 +325,18 @@ pub async fn upload_host_file_to_terminal(
|
||||
|
||||
// Ensure the destination directory exists rather than relying on Docker's
|
||||
// archive extractor to create the parent for the uploaded tar entry.
|
||||
crate::docker::exec::exec_oneshot(
|
||||
&container_id,
|
||||
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
|
||||
)
|
||||
.await?;
|
||||
target
|
||||
.exec_oneshot(vec![
|
||||
"mkdir".to_string(),
|
||||
"-p".to_string(),
|
||||
"/tmp/triple-c-drops".to_string(),
|
||||
])
|
||||
.await?;
|
||||
|
||||
let file_name = format!("triple-c-drops/{}", base);
|
||||
crate::docker::exec::upload_host_file_to_container(
|
||||
&container_id,
|
||||
&host_path,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
target
|
||||
.upload_host_file(&host_path, "/tmp", &file_name)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -347,8 +344,8 @@ pub async fn start_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Get container_id from the terminal session
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
// Get the container from the terminal session
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||
@@ -362,7 +359,7 @@ pub async fn start_audio_bridge(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session_with_tty(
|
||||
&container_id,
|
||||
&target,
|
||||
&audio_session_id,
|
||||
cmd,
|
||||
false,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Notices a project container that stopped without the app asking.
|
||||
//!
|
||||
//! Until this existed the app only ever learned a container's state from its
|
||||
//! own commands and from `reconcile_project_statuses`, which the frontend runs
|
||||
//! once, when Docker first answers. A container that died underneath a running
|
||||
//! app — the entrypoint's keepalive killed, an OOM, `docker stop` from a shell,
|
||||
//! a daemon restart — left its project at Running indefinitely: every terminal
|
||||
//! disconnected, while the sidebar, Start/Stop and Project Home all still
|
||||
//! described a live container.
|
||||
//!
|
||||
//! One subscription to the host's death reports (`host::watch`; for Docker,
|
||||
//! the daemon's event stream filtered to container `die` events, so an idle
|
||||
//! daemon costs nothing and the app hears about a death as it happens — no
|
||||
//! polling loop). Each `die` naming a project's container is
|
||||
//! confirmed with one inspect before anything changes (an event can be
|
||||
//! replayed, and a restart can reuse the id), and the flip itself is
|
||||
//! [`ProjectsStore::mark_exited`]'s compare-and-set, which never touches a
|
||||
//! project a command owns and refuses if the store changed while the inspect
|
||||
//! was in flight. The frontend hears it on [`CONTAINER_EXITED_EVENT`].
|
||||
//!
|
||||
//! The stream drops whenever the daemon does. Each (re)subscription asks for
|
||||
//! events since just before it, and first re-checks every Running project once —
|
||||
//! the only window that replay cannot cover is a daemon restart, which forgets
|
||||
//! its event history, and that is exactly when containers go away.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::host::watch::ExitSource;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Project, ProjectStatus};
|
||||
use crate::storage::projects_store::{ExitMark, ProjectsStore};
|
||||
|
||||
/// Emitted as a [`ContainerExited`] whenever this module flips a project to
|
||||
/// Stopped.
|
||||
pub const CONTAINER_EXITED_EVENT: &str = "project-container-exited";
|
||||
|
||||
const RETRY_MIN: Duration = Duration::from_secs(5);
|
||||
const RETRY_MAX: Duration = Duration::from_secs(60);
|
||||
/// A stream that stayed up this long (or delivered anything) was a working
|
||||
/// connection, so its loss starts the backoff over. Anything shorter — the
|
||||
/// daemon answers ping but the events call fails — keeps backing off, or it
|
||||
/// would re-sweep and re-log every five seconds forever.
|
||||
const STABLE_AFTER: Duration = Duration::from_secs(60);
|
||||
/// How often `settle` re-confirms when the store keeps changing under it.
|
||||
const SETTLE_ATTEMPTS: usize = 3;
|
||||
|
||||
/// How the app found out, which decides whether it is news to the user.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExitReason {
|
||||
/// The container died while this app was running and watching — any
|
||||
/// sessions it had were just cut off.
|
||||
Exited,
|
||||
/// Found already stopped on the first look of this run: it went down
|
||||
/// while the app was closed (a host reboot, typically). Nothing the user
|
||||
/// can see was interrupted, so it is not news.
|
||||
FoundStopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContainerExited<'a> {
|
||||
pub project: &'a Project,
|
||||
pub reason: ExitReason,
|
||||
}
|
||||
|
||||
fn project_busy(project_id: &str) -> bool {
|
||||
crate::project_lock::held(project_id).is_some()
|
||||
}
|
||||
|
||||
/// Confirm `container` really stopped and, if a project was Running on it,
|
||||
/// flip that project. Returns what changed.
|
||||
async fn settle(store: &ProjectsStore, container: &ContainerTarget) -> Option<Project> {
|
||||
let container_id = container.id();
|
||||
for _ in 0..SETTLE_ATTEMPTS {
|
||||
// Cheap and allocation-free: most containers that die are not ours
|
||||
// (probe and scratch containers, other software on the daemon), and
|
||||
// those cost no inspect at all.
|
||||
let seen = store.running_on(container_id)?;
|
||||
// Positive exit only: a host that can't answer must not mark every
|
||||
// project Stopped.
|
||||
if !container.has_exited().await {
|
||||
return None;
|
||||
}
|
||||
match store.mark_exited(container_id, &seen, project_busy) {
|
||||
ExitMark::Flipped(p) => return Some(*p),
|
||||
ExitMark::NotApplicable => return None,
|
||||
// The store moved while the host was answering — possibly a stop and
|
||||
// start of this very container. The inspect is stale; ask again.
|
||||
ExitMark::Changed => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Re-check every Running project once. Used on each (re)subscription.
|
||||
async fn sweep(store: &ProjectsStore) -> Vec<Project> {
|
||||
let mut changed = Vec::new();
|
||||
for p in store.list() {
|
||||
if p.status != ProjectStatus::Running {
|
||||
continue;
|
||||
}
|
||||
let Some(container) = ContainerTarget::for_project(&p) else { continue };
|
||||
if let Some(c) = settle(store, &container).await {
|
||||
changed.push(c);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The wait before connection attempt `n + 1`, after `n` consecutive
|
||||
/// unproductive ones: 5 s, 10 s, 20 s, 40 s, then 60 s for good.
|
||||
fn retry_delay(consecutive_failures: u32) -> Duration {
|
||||
let doublings = consecutive_failures.saturating_sub(1).min(8);
|
||||
(RETRY_MIN * 2u32.pow(doublings)).min(RETRY_MAX)
|
||||
}
|
||||
|
||||
/// `fut`, unless the app starts shutting down first. Every host call in here
|
||||
/// goes through this: a wedged daemon must not hold shutdown for its full
|
||||
/// budget.
|
||||
async fn unless_cancelled<T>(
|
||||
cancel: &mut watch::Receiver<bool>,
|
||||
fut: impl Future<Output = T>,
|
||||
) -> Option<T> {
|
||||
if *cancel.borrow() {
|
||||
return None;
|
||||
}
|
||||
tokio::select! {
|
||||
// `Err` means the sender is gone, i.e. so is the app.
|
||||
_ = cancel.changed() => None,
|
||||
v = fut => Some(v),
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection: ping, sweep, then follow the event stream until it ends.
|
||||
/// `None` when cancelled; otherwise whether the connection was a working one
|
||||
/// (see [`STABLE_AFTER`]).
|
||||
async fn watch_once(
|
||||
store: &ProjectsStore,
|
||||
cancel: &mut watch::Receiver<bool>,
|
||||
sweep_reason: ExitReason,
|
||||
on_change: &impl Fn(&Project, ExitReason),
|
||||
) -> Option<bool> {
|
||||
let source = ExitSource::Docker;
|
||||
if !unless_cancelled(cancel, source.reachable()).await? {
|
||||
return Some(false);
|
||||
}
|
||||
// A second's overlap: the host replays from `since`, so a death during
|
||||
// the sweep below is still delivered. Duplicates are harmless — `settle`
|
||||
// confirms with an inspect every time.
|
||||
let since = chrono::Utc::now().timestamp() - 1;
|
||||
for p in unless_cancelled(cancel, sweep(store)).await? {
|
||||
on_change(&p, sweep_reason);
|
||||
}
|
||||
let mut deaths = source.deaths(since);
|
||||
let connected = Instant::now();
|
||||
let mut delivered = false;
|
||||
loop {
|
||||
match unless_cancelled(cancel, deaths.next()).await? {
|
||||
Some(Ok(container)) => {
|
||||
delivered = true;
|
||||
if let Some(p) = unless_cancelled(cancel, settle(store, &container)).await? {
|
||||
on_change(&p, ExitReason::Exited);
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::info!("Container watch: event stream ended ({e}) — reconnecting");
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Container watch: event stream closed — reconnecting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(delivered || connected.elapsed() >= STABLE_AFTER)
|
||||
}
|
||||
|
||||
/// Watch until `cancel` flips, calling `on_change` for every project whose
|
||||
/// container is found to have stopped on its own. Separate from [`spawn`] so a
|
||||
/// test can drive it without a Tauri app.
|
||||
pub(crate) async fn run(
|
||||
store: Arc<ProjectsStore>,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
on_change: impl Fn(&Project, ExitReason),
|
||||
) {
|
||||
// Only the first look of the run finds containers that stopped while the
|
||||
// app was closed. A sweep after a reconnect finds ones that died while we
|
||||
// were running — the daemon restarting is the usual cause — and those
|
||||
// cut off live sessions just as a `die` event would.
|
||||
let mut sweep_reason = ExitReason::FoundStopped;
|
||||
let mut failures: u32 = 0;
|
||||
loop {
|
||||
let Some(healthy) = watch_once(&store, &mut cancel, sweep_reason, &on_change).await else {
|
||||
return;
|
||||
};
|
||||
sweep_reason = ExitReason::Exited;
|
||||
failures = if healthy { 1 } else { failures + 1 };
|
||||
unless_cancelled(&mut cancel, tokio::time::sleep(retry_delay(failures))).await;
|
||||
if *cancel.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the watcher for the life of the app.
|
||||
pub fn spawn(
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
cancel: watch::Receiver<bool>,
|
||||
) -> tauri::async_runtime::JoinHandle<()> {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run(store, cancel, |p, reason| {
|
||||
log::warn!(
|
||||
"Project '{}' ({}): its container stopped without the app asking ({:?}) — marking Stopped",
|
||||
p.name,
|
||||
p.id,
|
||||
reason
|
||||
);
|
||||
let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: p, reason });
|
||||
})
|
||||
.await;
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn retries_back_off_to_a_minute_and_stay_there() {
|
||||
let secs: Vec<u64> = (1..=7).map(|n| retry_delay(n).as_secs()).collect();
|
||||
assert_eq!(secs, vec![5, 10, 20, 40, 60, 60, 60]);
|
||||
assert_eq!(retry_delay(0), RETRY_MIN);
|
||||
assert_eq!(retry_delay(u32::MAX), RETRY_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_event_says_why_so_the_ui_can_tell_news_from_housekeeping() {
|
||||
let p = Project::new("demo".to_string(), Vec::new());
|
||||
let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::Exited }).unwrap();
|
||||
assert_eq!(v["reason"], "exited");
|
||||
assert_eq!(v["project"]["name"], "demo");
|
||||
let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::FoundStopped })
|
||||
.unwrap();
|
||||
assert_eq!(v["reason"], "found_stopped");
|
||||
assert_eq!(CONTAINER_EXITED_EVENT, "project-container-exited");
|
||||
}
|
||||
|
||||
/// The shutdown path: a host call that never answers (a wedged daemon)
|
||||
/// must give way to cancellation at once, not hold teardown.
|
||||
#[tokio::test]
|
||||
async fn a_hung_docker_call_yields_to_cancellation() {
|
||||
let (tx, mut rx) = watch::channel(false);
|
||||
let canceller = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let _ = tx.send(true);
|
||||
});
|
||||
let hung = std::future::pending::<()>();
|
||||
let out = tokio::time::timeout(Duration::from_secs(2), unless_cancelled(&mut rx, hung)).await;
|
||||
assert_eq!(out.expect("returned promptly"), None);
|
||||
canceller.await.unwrap();
|
||||
// Already cancelled: it does not even start the call.
|
||||
assert_eq!(unless_cancelled(&mut rx, async { 1 }).await, None);
|
||||
}
|
||||
|
||||
/// End to end against a real daemon: a Running project whose container is
|
||||
/// killed from outside the app is flipped to Stopped and reported as a live
|
||||
/// exit; one found already stopped on the first look is reported as such;
|
||||
/// and one whose container is still up is left alone.
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates and removes throwaway containers"]
|
||||
async fn a_container_killed_outside_the_app_flips_its_project_to_stopped() {
|
||||
use std::process::Command;
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = Command::new("docker").args(args).output().expect("docker cli");
|
||||
assert!(out.status.success(), "docker {:?}: {}", args, String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
let tag = uuid::Uuid::new_v4().simple().to_string();
|
||||
let doomed = docker_cli(&["run", "-d", "--name", &format!("tc-watch-a-{tag}"), "alpine:3", "sleep", "300"]);
|
||||
let alive = docker_cli(&["run", "-d", "--name", &format!("tc-watch-b-{tag}"), "alpine:3", "sleep", "300"]);
|
||||
let already = docker_cli(&["create", "--name", &format!("tc-watch-c-{tag}"), "alpine:3", "true"]);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("triple-c-watch-{tag}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let running = |name: &str, cid: &str| {
|
||||
let mut p = Project::new(name.to_string(), Vec::new());
|
||||
p.container_id = Some(cid.to_string());
|
||||
p.status = ProjectStatus::Running;
|
||||
p
|
||||
};
|
||||
let (a, b, c) = (running("a", &doomed), running("b", &alive), running("c", &already));
|
||||
let (a_id, b_id, c_id) = (a.id.clone(), b.id.clone(), c.id.clone());
|
||||
let store = Arc::new(ProjectsStore::over_file_for_test(dir.join("projects.json"), vec![a, b, c]));
|
||||
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<(String, ExitReason)>();
|
||||
let watcher = tokio::spawn(run(store.clone(), cancel_rx, move |p, why| {
|
||||
let _ = seen_tx.send((p.id.clone(), why));
|
||||
}));
|
||||
let first = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv()).await;
|
||||
// Subscribed by now (the sweep precedes it, and `since` overlaps);
|
||||
// kill from "outside".
|
||||
docker_cli(&["kill", &doomed]);
|
||||
let second = tokio::time::timeout(Duration::from_secs(15), seen_rx.recv()).await;
|
||||
|
||||
let t0 = Instant::now();
|
||||
let _ = cancel_tx.send(true);
|
||||
let _ = watcher.await;
|
||||
let shutdown = t0.elapsed();
|
||||
let statuses: Vec<ProjectStatus> =
|
||||
[&a_id, &b_id, &c_id].iter().map(|id| store.get(id).unwrap().status).collect();
|
||||
docker_cli(&["rm", "-f", &doomed, &alive, &already]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
assert_eq!(first.expect("sweep within 10s"), Some((c_id, ExitReason::FoundStopped)));
|
||||
assert_eq!(second.expect("die within 15s"), Some((a_id, ExitReason::Exited)));
|
||||
assert_eq!(statuses, vec![ProjectStatus::Stopped, ProjectStatus::Running, ProjectStatus::Stopped]);
|
||||
assert!(shutdown < Duration::from_secs(1), "stopped watching in {shutdown:?}");
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,13 @@ pub async fn check_docker_available() -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon socket a project container gets bind-mounted when Docker access
|
||||
/// is enabled and no path is configured.
|
||||
pub fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use sha2::{Sha256, Digest};
|
||||
|
||||
use super::ca_certs;
|
||||
use super::client::get_docker;
|
||||
use crate::models::AppSettings;
|
||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
@@ -2015,7 +2016,7 @@ pub const CONFIG_VOLUME_PREFIX: &str = "triple-c-claude-config-";
|
||||
/// `aws_config_path` is configured), since the entrypoint already refreshes
|
||||
/// `~/.aws` from the host on every start in that case.
|
||||
pub async fn sync_bedrock_credentials(
|
||||
container_id: &str,
|
||||
target: &crate::host::ContainerTarget,
|
||||
project: &Project,
|
||||
) -> Result<(), String> {
|
||||
let static_bedrock = if project.backend == Backend::Bedrock {
|
||||
@@ -2038,10 +2039,10 @@ pub async fn sync_bedrock_credentials(
|
||||
let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#;
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
let env = vec!["HOME=/home/claude".to_string()];
|
||||
if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await {
|
||||
if let Err(e) = target.exec_oneshot_env(cmd, env).await {
|
||||
log::warn!(
|
||||
"Failed to clear stale AWS credentials in container {}: {}",
|
||||
container_id,
|
||||
target.id(),
|
||||
e
|
||||
);
|
||||
}
|
||||
@@ -2093,7 +2094,7 @@ chmod 600 "$HOME/.aws/credentials""#;
|
||||
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
let (output, exit_code) =
|
||||
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
|
||||
target.exec_oneshot_env_status(cmd, env)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
|
||||
if exit_code != 0 {
|
||||
@@ -2104,7 +2105,7 @@ chmod 600 "$HOME/.aws/credentials""#;
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Wrote Bedrock static credentials into container {}", container_id);
|
||||
log::info!("Wrote Bedrock static credentials into container {}", target.id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4143,6 +4144,49 @@ pub async fn is_container_running(container_id: &str) -> Result<bool, String> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the ordinary create paths (`host::lifecycle`) and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The Docker half of `container_watch`: reachability, the daemon's container
|
||||
//! `die` event stream, and the "has it positively exited" check. Everything
|
||||
//! about *what to do* with a death (confirm, compare-and-set the store, tell
|
||||
//! the frontend, back off) is host-neutral and stays in `container_watch.rs`,
|
||||
//! which reaches these through `host::watch`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
|
||||
use bollard::errors::Error as DockerError;
|
||||
use bollard::models::{EventMessage, EventMessageTypeEnum};
|
||||
use bollard::system::EventsOptions;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
|
||||
use super::client::get_docker;
|
||||
|
||||
/// Whether the daemon answers at all.
|
||||
pub async fn ping() -> bool {
|
||||
match get_docker() {
|
||||
Ok(docker) => docker.ping().await.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The container id a Docker event reports as having died, if it is one.
|
||||
pub(crate) fn died_container(msg: &EventMessage) -> Option<&str> {
|
||||
if msg.typ != Some(EventMessageTypeEnum::CONTAINER) || msg.action.as_deref() != Some("die") {
|
||||
return None;
|
||||
}
|
||||
msg.actor.as_ref()?.id.as_deref().filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
fn die_filter() -> HashMap<String, Vec<String>> {
|
||||
HashMap::from([
|
||||
("type".to_string(), vec!["container".to_string()]),
|
||||
("event".to_string(), vec!["die".to_string()]),
|
||||
])
|
||||
}
|
||||
|
||||
/// Container ids as Docker reports them dying, replayed from `since` (unix
|
||||
/// seconds). Filtered daemon-side to container `die` events, so an idle daemon
|
||||
/// costs nothing. Anything else that comes through is dropped here. An `Err`
|
||||
/// item, or the stream ending, means the connection is gone.
|
||||
pub fn container_deaths(since: i64) -> Pin<Box<dyn Stream<Item = Result<String, String>> + Send>> {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
|
||||
};
|
||||
let events = docker.events(Some(EventsOptions::<String> {
|
||||
since: Some(since.to_string()),
|
||||
until: None,
|
||||
filters: die_filter(),
|
||||
}));
|
||||
Box::pin(events.filter_map(|item| async move {
|
||||
match item {
|
||||
Ok(msg) => died_container(&msg).map(|id| Ok(id.to_string())),
|
||||
Err(e) => Some(Err(e.to_string())),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// `true` only when Docker positively says the container is not running: it
|
||||
/// inspects as stopped, or it no longer exists. Any other failure is "don't
|
||||
/// know" — `is_container_running` maps every error to `false`, which is right
|
||||
/// for its callers and wrong for the watcher, where it would mark every project
|
||||
/// Stopped the moment the daemon hiccups.
|
||||
pub async fn has_exited(container_id: &str) -> bool {
|
||||
let Ok(docker) = get_docker() else { return false };
|
||||
match docker.inspect_container(container_id, None).await {
|
||||
Ok(info) => !info.state.and_then(|s| s.running).unwrap_or(false),
|
||||
Err(DockerError::DockerResponseServerError { status_code: 404, .. }) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bollard::models::EventActor;
|
||||
|
||||
fn event(typ: EventMessageTypeEnum, action: &str, id: Option<&str>) -> EventMessage {
|
||||
EventMessage {
|
||||
typ: Some(typ),
|
||||
action: Some(action.to_string()),
|
||||
actor: Some(EventActor { id: id.map(str::to_string), attributes: None }),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_container_die_names_a_container() {
|
||||
let die = event(EventMessageTypeEnum::CONTAINER, "die", Some("abc"));
|
||||
assert_eq!(died_container(&die), Some("abc"));
|
||||
|
||||
for (typ, action, id) in [
|
||||
(EventMessageTypeEnum::CONTAINER, "stop", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "start", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "exec_die", Some("abc")),
|
||||
(EventMessageTypeEnum::NETWORK, "die", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", None),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", Some("")),
|
||||
] {
|
||||
assert_eq!(died_container(&event(typ, action, id)), None, "{typ:?} {action} {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subscription_is_filtered_to_container_deaths_daemon_side() {
|
||||
let f = die_filter();
|
||||
assert_eq!(f.get("type"), Some(&vec!["container".to_string()]));
|
||||
assert_eq!(f.get("event"), Some(&vec!["die".to_string()]));
|
||||
assert_eq!(f.len(), 2);
|
||||
}
|
||||
}
|
||||
+240
-363
@@ -1,58 +1,44 @@
|
||||
use bollard::container::{LogOutput, UploadToContainerOptions};
|
||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::host::{ExecChunk, ExecSpec};
|
||||
|
||||
/// A `docker exec` that has been created and started with stdin/stdout/stderr
|
||||
/// attached — the raw duplex halves, before any policy about what to do with
|
||||
/// them.
|
||||
///
|
||||
/// This is the single place in the codebase that knows how to open an attached
|
||||
/// exec. Both consumers are built on it:
|
||||
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
|
||||
/// which pump bytes through mpsc channels and a callback.
|
||||
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
|
||||
/// straight between a host TCP socket and these halves.
|
||||
///
|
||||
/// With `tty = false` the output stream is demultiplexed by Docker, so the
|
||||
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
|
||||
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
|
||||
/// spliced into the proxied byte stream.
|
||||
pub struct AttachedExec {
|
||||
pub exec_id: String,
|
||||
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||
}
|
||||
// `AttachedExec` lives in `host` now (host-neutral output chunks); this
|
||||
// module's job is to produce one from Docker. Its consumers are
|
||||
// `host::sessions::ExecSessionManager` (terminals, audio bridge) and the auth
|
||||
// bridge's per-connection `socat` tunnels, both through `host::ContainerTarget`.
|
||||
// With `tty = false` Docker demultiplexes stdout from stderr, which the auth
|
||||
// bridge relies on: `socat`'s diagnostics must not be spliced into the proxied
|
||||
// byte stream.
|
||||
pub use crate::host::AttachedExec;
|
||||
|
||||
/// Create and start an exec with stdin + stdout + stderr attached, returning the
|
||||
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
|
||||
/// this app opens.
|
||||
pub async fn create_attached_exec(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
) -> Result<AttachedExec, String> {
|
||||
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
|
||||
}
|
||||
|
||||
/// [`create_attached_exec`] with the user and working directory spelled out.
|
||||
/// An attached exec with the user and working directory spelled out.
|
||||
///
|
||||
/// Only base-image migration needs this: replaying `apt` and unpacking a
|
||||
/// payload tar at `/` have to run as **root**, and every other caller wants the
|
||||
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
|
||||
/// stays the single place an attached exec is opened.
|
||||
/// Only base-image migration calls this directly: replaying `apt` and unpacking
|
||||
/// a payload tar at `/` have to run as **root**. Everything else goes through
|
||||
/// `host::ContainerTarget::attach`.
|
||||
pub async fn create_attached_exec_as(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
user: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let spec = ExecSpec::new(cmd)
|
||||
.tty(tty)
|
||||
.user(user)
|
||||
.working_dir(Some(working_dir));
|
||||
create_attached_exec_spec(container_id, &spec).await
|
||||
}
|
||||
|
||||
/// The single place an attached exec is opened on Docker. Everything else —
|
||||
/// [`create_attached_exec_as`] and `host::ContainerTarget::attach` — is a thin
|
||||
/// wrapper over this.
|
||||
pub async fn create_attached_exec_spec(
|
||||
container_id: &str,
|
||||
spec: &ExecSpec,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -60,13 +46,14 @@ pub async fn create_attached_exec_as(
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdin: Some(spec.attach_stdin),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some(user.to_string()),
|
||||
working_dir: Some(working_dir.to_string()),
|
||||
tty: Some(spec.tty),
|
||||
cmd: Some(spec.cmd.clone()),
|
||||
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
|
||||
user: Some(spec.user.clone()),
|
||||
working_dir: spec.working_dir.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -82,244 +69,87 @@ pub async fn create_attached_exec_as(
|
||||
{
|
||||
StartExecResults::Attached { output, input } => Ok(AttachedExec {
|
||||
exec_id,
|
||||
output,
|
||||
output: Box::pin(output.map(|r| {
|
||||
r.map(log_output_to_chunk)
|
||||
.map_err(|e| e.to_string())
|
||||
})),
|
||||
input,
|
||||
}),
|
||||
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub container_id: String,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&self.exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
/// bollard's `LogOutput` → the host-neutral [`ExecChunk`]. `Console` is what
|
||||
/// Docker calls a TTY stream; it has no stderr, so it is stdout.
|
||||
pub fn log_output_to_chunk(o: LogOutput) -> ExecChunk {
|
||||
match o {
|
||||
LogOutput::StdOut { message } | LogOutput::Console { message } => ExecChunk::StdOut(message),
|
||||
LogOutput::StdErr { message } => ExecChunk::StdErr(message),
|
||||
LogOutput::StdIn { message } => ExecChunk::Other(message),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
/// Start a command with nothing attached and return its exec id without
|
||||
/// waiting for it.
|
||||
pub async fn exec_detached(container_id: &str, spec: &ExecSpec) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(false),
|
||||
attach_stderr: Some(false),
|
||||
tty: Some(spec.tty),
|
||||
cmd: Some(spec.cmd.clone()),
|
||||
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
|
||||
user: Some(spec.user.clone()),
|
||||
working_dir: spec.working_dir.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
docker
|
||||
.start_exec(
|
||||
&exec.id,
|
||||
Some(bollard::exec::StartExecOptions {
|
||||
detach: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
Ok(exec.id)
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let AttachedExec {
|
||||
pub async fn resize_exec(exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = create_attached_exec(container_id, cmd, tty).await?;
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// Output reader task
|
||||
let session_id_clone = session_id.to_string();
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = output.next() => {
|
||||
match msg {
|
||||
Some(Ok(output)) => {
|
||||
on_output(output.into_bytes().to_vec());
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Exec output stream ended for {}", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
on_exit();
|
||||
let _ = shutdown_tx_clone;
|
||||
});
|
||||
|
||||
// Input writer task
|
||||
tokio::spawn(async move {
|
||||
while let Some(data) = input_rx.recv().await {
|
||||
if let Err(e) = input.write_all(&data).await {
|
||||
log::error!("Failed to write to exec stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
container_id: container_id.to_string(),
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
// Clone the exec_id under the lock, then drop the lock before the
|
||||
// async Docker API call to avoid holding the mutex across await.
|
||||
let exec_id = {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.exec_id.clone()
|
||||
};
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let ids_to_close: Vec<String> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.container_id == container_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids_to_close {
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
Ok(session.container_id.clone())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_container(
|
||||
&self,
|
||||
container_id: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", file_name))
|
||||
}
|
||||
/// Docker's archive API: `path` as a tar stream. Errors are stringified so the
|
||||
/// stream is host-neutral.
|
||||
pub fn download_archive(container_id: &str, path: &str) -> crate::host::ArchiveStream {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
|
||||
};
|
||||
let stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(bollard::container::DownloadFromContainerOptions {
|
||||
path: path.to_string(),
|
||||
}),
|
||||
);
|
||||
Box::pin(stream.map(|r| r.map_err(|e| e.to_string())))
|
||||
}
|
||||
|
||||
/// Ceiling on one host file packed into a container upload.
|
||||
@@ -376,31 +206,8 @@ pub async fn upload_host_file_with_ids(
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
&file,
|
||||
std::path::Path::new(&host_path),
|
||||
)?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||
&mut data,
|
||||
)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
if data.len() as u64 > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to upload (limit {} MB)",
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||
let data = read_host_file_for_upload(&host_path)?;
|
||||
build_single_file_tar(&dest_for_blk, &data[..], HOST_FILE_UPLOAD_MODE, uid, gid, mtime)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||
@@ -421,13 +228,46 @@ pub async fn upload_host_file_with_ids(
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// The mode a host file lands with in the container, on every host.
|
||||
pub(crate) const HOST_FILE_UPLOAD_MODE: u32 = 0o644;
|
||||
|
||||
/// Read a host file that is about to be copied into a container. Blocking:
|
||||
/// call it from `spawn_blocking`. Shared by every host's upload so the checks
|
||||
/// cannot drift apart.
|
||||
///
|
||||
/// The caller resolved this path (`resolve_host_read_path`); opening it is a
|
||||
/// second trip through the same directories, so the descriptor is checked
|
||||
/// against the path that was validated before its bytes are packed into
|
||||
/// anything. Two paths reach here: the terminal's drop target, and the Files
|
||||
/// pane's upload via `upload_host_file_with_ids`. Between them they are how
|
||||
/// host bytes enter a container. [`MAX_DROP_BYTES`] is applied to what the
|
||||
/// open descriptor actually yields, not to an earlier `stat`.
|
||||
pub(crate) fn read_host_file_for_upload(host_path: &str) -> Result<Vec<u8>, String> {
|
||||
let file = std::fs::File::open(host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(&file, std::path::Path::new(host_path))?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||
&mut data,
|
||||
)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
if data.len() as u64 > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to upload (limit {} MB)",
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
pub(crate) fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
@@ -444,14 +284,22 @@ pub async fn upload_bytes_to_container(
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
upload_bytes_with_ids(container_id, dest_dir, file_name, data, mode, (0, 0)).await
|
||||
}
|
||||
|
||||
/// [`upload_bytes_to_container`] with the tar header's owner set, stamped now.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
container_id: &str,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Root-owned on purpose: migration's `tar -T` list is read back as root,
|
||||
// and the marketplace sync uploads into a `claude`-owned directory it
|
||||
// prepares first, so `claude` can still read and delete the files. The
|
||||
// mtime still gets stamped so the file doesn't read as 1970.
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -521,14 +369,29 @@ pub fn now_epoch_secs() -> u64 {
|
||||
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
|
||||
/// fails, which is strictly better than the 0:0 a default tar header carries.
|
||||
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||
let out = exec_oneshot_limited(
|
||||
let (out, _) = exec_oneshot_limited(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||
256,
|
||||
"claude",
|
||||
user_ids_cmd(),
|
||||
USER_IDS_OUTPUT_LIMIT,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
parse_user_ids(&out)
|
||||
}
|
||||
|
||||
/// The command [`container_user_ids`] runs (as `claude`, under
|
||||
/// [`USER_IDS_OUTPUT_LIMIT`]); every host asks the same question.
|
||||
pub(crate) fn user_ids_cmd() -> Vec<String> {
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()]
|
||||
}
|
||||
|
||||
/// Output ceiling for the uid probe: two short numbers.
|
||||
pub(crate) const USER_IDS_OUTPUT_LIMIT: usize = 256;
|
||||
|
||||
/// `id -u; id -g` output -> `(uid, gid)`, falling back to the image's
|
||||
/// build-time `(1000, 1000)`.
|
||||
pub(crate) fn parse_user_ids(out: &str) -> (u64, u64) {
|
||||
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
|
||||
match (ids.next(), ids.next()) {
|
||||
(Some(uid), Some(gid)) => (uid, gid),
|
||||
@@ -543,9 +406,12 @@ pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||
/// caller sees a byte, and what it is reading is *container-controlled* — the
|
||||
/// scheduler notifications reader `cat`s up to 50 files with no size cap, and
|
||||
/// the auth bridge reads `/proc/net/tcp` every two seconds. Neither has an
|
||||
/// upstream bound, so this is where the bound goes. Generous enough that no
|
||||
/// legitimate reader (the largest is a package manifest of a full image) comes
|
||||
/// close.
|
||||
/// upstream bound, so this is where the bound goes. It is the default, not a
|
||||
/// promise that every reader fits: the migration manifest of a running
|
||||
/// container did pass it (8.6 MB from a busy `/workspace`), and now runs under
|
||||
/// its own `migration::MANIFEST_OUTPUT_LIMIT` through [`exec_oneshot_limited`].
|
||||
/// A reader that can legitimately grow with the user's files should do the
|
||||
/// same rather than raise this.
|
||||
pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// The auth bridge's per-tick budget. It reads two procfs files whose rows are
|
||||
@@ -586,16 +452,20 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
||||
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||
}
|
||||
|
||||
/// [`exec_oneshot`] with a caller-chosen output ceiling, for readers whose
|
||||
/// input is fully container-controlled and whose legitimate output is small.
|
||||
/// A one-shot exec as `user` with a caller-chosen output ceiling, returning the
|
||||
/// combined output and the exit code.
|
||||
///
|
||||
/// For readers whose budget is not [`MAX_ONESHOT_OUTPUT`]: smaller where the
|
||||
/// input is container-controlled and the legitimate output tiny (the auth
|
||||
/// bridge's procfs poll, the uid probe), larger where it can genuinely pass
|
||||
/// 8 MiB (the migration manifest, as root).
|
||||
pub async fn exec_oneshot_limited(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<String, String> {
|
||||
exec_oneshot_inner(container_id, "claude", cmd, Vec::new(), limit)
|
||||
.await
|
||||
.map(|(output, _)| output)
|
||||
) -> Result<(String, i64), String> {
|
||||
exec_oneshot_inner(container_id, user, cmd, Vec::new(), limit).await
|
||||
}
|
||||
|
||||
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||
@@ -649,21 +519,54 @@ pub async fn exec_oneshot_as(
|
||||
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
|
||||
/// caller that is *parsing* output can have just that without the buffer being
|
||||
/// held twice.
|
||||
struct OneshotOutput {
|
||||
pub(crate) struct OneshotOutput {
|
||||
combined: Vec<u8>,
|
||||
stdout_ranges: Vec<(usize, usize)>,
|
||||
exit_code: i64,
|
||||
pub(crate) exit_code: i64,
|
||||
}
|
||||
|
||||
/// The refusal for output past `limit`, starting with [`OUTPUT_LIMIT_MARKER`].
|
||||
pub(crate) fn output_limit_error(limit: usize) -> String {
|
||||
format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, limit
|
||||
)
|
||||
}
|
||||
|
||||
impl OneshotOutput {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
combined: Vec::new(),
|
||||
stdout_ranges: Vec::new(),
|
||||
exit_code: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one chunk in arrival order, or refuse it (appending nothing)
|
||||
/// once the combined output would pass `limit`. The caller must stop
|
||||
/// reading on an error rather than truncate silently: every caller parses
|
||||
/// this output, and a half-read manifest or JSON array is worse than an
|
||||
/// error.
|
||||
pub(crate) fn push(&mut self, from_stdout: bool, bytes: &[u8], limit: usize) -> Result<(), String> {
|
||||
match push_capped(&mut self.combined, bytes, limit) {
|
||||
Some(range) => {
|
||||
if from_stdout {
|
||||
self.stdout_ranges.push(range);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(output_limit_error(limit)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the command printed, in the order it printed it.
|
||||
fn text(&self) -> String {
|
||||
pub(crate) fn text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.combined).into_owned()
|
||||
}
|
||||
|
||||
/// stdout alone — for callers that parse it, where a diagnostic spliced in
|
||||
/// mid-record is a parse error at best.
|
||||
fn stdout(&self) -> String {
|
||||
pub(crate) fn stdout(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[*start..*end]);
|
||||
@@ -672,7 +575,7 @@ impl OneshotOutput {
|
||||
}
|
||||
|
||||
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
|
||||
fn stderr(&self) -> String {
|
||||
pub(crate) fn stderr(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
let mut cursor = 0usize;
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
@@ -741,8 +644,7 @@ async fn exec_oneshot_raw(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
let mut combined: Vec<u8> = Vec::new();
|
||||
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
|
||||
let mut out = OneshotOutput::new();
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(msg) = output.next().await {
|
||||
@@ -750,23 +652,9 @@ async fn exec_oneshot_raw(
|
||||
Ok(data) => {
|
||||
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||
let bytes = data.into_bytes();
|
||||
match push_capped(&mut combined, &bytes, limit) {
|
||||
Some(range) => {
|
||||
if from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
// Stop reading rather than truncate silently: every
|
||||
// caller parses this output, and a half-read
|
||||
// manifest or JSON array is worse than an error.
|
||||
// Dropping `output` kills the exec's stream.
|
||||
None => {
|
||||
return Err(format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, limit
|
||||
))
|
||||
}
|
||||
}
|
||||
// Over the limit: returning drops `output`, which
|
||||
// kills the exec's stream.
|
||||
out.push(from_stdout, &bytes, limit)?;
|
||||
}
|
||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||
}
|
||||
@@ -777,13 +665,8 @@ async fn exec_oneshot_raw(
|
||||
|
||||
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||
// final exit_code populated yet, so poll until the exec reports finished.
|
||||
let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||
|
||||
Ok(OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code,
|
||||
})
|
||||
out.exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Turn "the exit code could not be determined" into an error rather than a 0.
|
||||
@@ -816,7 +699,7 @@ fn require_exit_code(code: Option<i64>) -> Result<i64, String> {
|
||||
/// spurious "the rename failed" and a real one.
|
||||
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
let docker = get_docker().ok()?;
|
||||
for _ in 0..200 {
|
||||
for _ in 0..EXEC_EXIT_POLLS {
|
||||
match docker.inspect_exec(exec_id).await {
|
||||
Ok(info) => {
|
||||
if info.running != Some(true) {
|
||||
@@ -835,30 +718,27 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// [`wait_for_exec_exit`]'s budget: polls, and the pause between them. Every
|
||||
/// host's `wait_exec_exit` uses the same numbers.
|
||||
pub(crate) const EXEC_EXIT_POLLS: usize = 200;
|
||||
pub(crate) const EXEC_EXIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`.
|
||||
fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput {
|
||||
let mut combined = Vec::new();
|
||||
let mut stdout_ranges = Vec::new();
|
||||
let mut out = OneshotOutput::new();
|
||||
for (from_stdout, bytes) in frames {
|
||||
let range = push_capped(&mut combined, bytes, usize::MAX).unwrap();
|
||||
if *from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code: 0,
|
||||
out.push(*from_stdout, bytes, usize::MAX).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -917,10 +797,7 @@ mod tests {
|
||||
// The marker is what lets `list_container_files` say "too many entries"
|
||||
// instead, which is the thing that actually happened.
|
||||
assert!(!OUTPUT_LIMIT_MARKER.is_empty());
|
||||
let refusal = format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT
|
||||
);
|
||||
let refusal = output_limit_error(MAX_ONESHOT_OUTPUT);
|
||||
assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,16 @@ const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runn
|
||||
const OSC52_CLIPBOARD: &str = include_str!("../../../../container/osc52-clipboard");
|
||||
const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim");
|
||||
const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh");
|
||||
const TRIPLE_C_OPEN: &str = include_str!("../../../../container/triple-c-open");
|
||||
const PLAYWRIGHT_HEAL: &str = include_str!("../../../../container/triple-c-playwright-heal");
|
||||
|
||||
static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control");
|
||||
static SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/skills");
|
||||
// The agent crate's sources only — never the whole crate directory, which can
|
||||
// hold a multi-hundred-megabyte `target/` from a local build.
|
||||
const AGENT_CARGO_TOML: &str = include_str!("../../../../container/triple-c-agent/Cargo.toml");
|
||||
const AGENT_CARGO_LOCK: &str = include_str!("../../../../container/triple-c-agent/Cargo.lock");
|
||||
static AGENT_SRC_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/triple-c-agent/src");
|
||||
|
||||
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
@@ -137,10 +145,28 @@ where
|
||||
|
||||
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
|
||||
|
||||
// The Dockerfile builds `triple-c-agent` in a stage pinned to
|
||||
// `$BUILDPLATFORM` and cross-compiles for `$TARGETARCH`, so CI's arm64
|
||||
// image compiles natively instead of under QEMU. BuildKit predefines both;
|
||||
// the classic builder this API call uses does not, and an empty
|
||||
// `--platform=` fails the build outright (verified, Engine 29.9). So pass
|
||||
// them, describing the daemon's own platform: a local build is always
|
||||
// native.
|
||||
let arch = docker
|
||||
.version()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.arch)
|
||||
.filter(|a| !a.is_empty())
|
||||
.unwrap_or_else(|| native_docker_arch().to_string());
|
||||
let build_platform = format!("linux/{}", arch);
|
||||
let buildargs = local_build_args(&build_platform, &arch);
|
||||
|
||||
let options = BuildImageOptions {
|
||||
t: full_name.as_str(),
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
buildargs,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -191,6 +217,20 @@ fn append_embedded_dir(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Docker's name for this machine's architecture, for when the daemon does
|
||||
/// not say.
|
||||
fn native_docker_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "amd64",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_build_args<'a>(build_platform: &'a str, arch: &'a str) -> HashMap<&'a str, &'a str> {
|
||||
HashMap::from([("BUILDPLATFORM", build_platform), ("TARGETARCH", arch)])
|
||||
}
|
||||
|
||||
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
@@ -203,8 +243,14 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
append_file_to_archive(&mut archive, "osc52-clipboard", OSC52_CLIPBOARD.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "audio-shim", AUDIO_SHIM.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-open", TRIPLE_C_OPEN.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-playwright-heal", PLAYWRIGHT_HEAL.as_bytes(), 0o755)?;
|
||||
|
||||
append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?;
|
||||
append_embedded_dir(&mut archive, &SKILLS_DIR, "skills")?;
|
||||
append_file_to_archive(&mut archive, "triple-c-agent/Cargo.toml", AGENT_CARGO_TOML.as_bytes(), 0o644)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-agent/Cargo.lock", AGENT_CARGO_LOCK.as_bytes(), 0o644)?;
|
||||
append_embedded_dir(&mut archive, &AGENT_SRC_DIR, "triple-c-agent/src")?;
|
||||
|
||||
archive.finish()?;
|
||||
}
|
||||
@@ -213,6 +259,46 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod build_context_tests {
|
||||
use super::{create_build_context, DOCKERFILE};
|
||||
|
||||
/// The local build (`ImageSource::LocalBuild`) ships the Dockerfile with a
|
||||
/// hand-assembled context, and nothing else checks the two agree: CI builds
|
||||
/// from the real `container/` directory, so a `COPY` of something missing
|
||||
/// here only fails for users who build locally. That went unnoticed for
|
||||
/// `skills`, `triple-c-open` and `triple-c-playwright-heal`. Every `COPY`
|
||||
/// source (not `--from=` stages) must be present as a file or directory
|
||||
/// prefix in the archive.
|
||||
#[test]
|
||||
fn every_dockerfile_copy_source_is_in_the_local_build_context() {
|
||||
let tar_bytes = create_build_context().expect("context builds");
|
||||
let mut archive = tar::Archive::new(&tar_bytes[..]);
|
||||
let paths: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let mut missing = Vec::new();
|
||||
for line in DOCKERFILE.lines() {
|
||||
let line = line.trim();
|
||||
let Some(rest) = line.strip_prefix("COPY ") else { continue };
|
||||
let args: Vec<&str> = rest.split_whitespace().filter(|a| !a.starts_with("--")).collect();
|
||||
if rest.contains("--from=") || args.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for src in &args[..args.len() - 1] {
|
||||
let src = src.trim_end_matches('/');
|
||||
let present = paths.iter().any(|p| p == src || p.starts_with(&format!("{}/", src)));
|
||||
if !present {
|
||||
missing.push(src.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "COPY sources missing from the local build context: {:?}", missing);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::ENTRYPOINT;
|
||||
|
||||
@@ -94,6 +94,68 @@ pub const MANIFEST_ROOTS: &[&str] = &[
|
||||
"/var/www",
|
||||
];
|
||||
|
||||
/// [`MANIFEST_ROOTS`] entries reported only to [`SUMMARY_DEPTH`], with every
|
||||
/// directory at that depth that holds at least one non-directory (at any depth)
|
||||
/// reported as one [`SUMMARY_KIND`] entry whose size is the sum of those
|
||||
/// files' sizes — exactly what [`verbatim_payload_bytes`] would have summed
|
||||
/// from a per-file listing. A directory holding no file is reported as a plain
|
||||
/// `d`, so the empty-tree filter still drops it.
|
||||
///
|
||||
/// **Why `/workspace`, and why it has to be.** It is the one root whose size is
|
||||
/// the user's rather than the image's: git worktrees, `node_modules`, a pnpm
|
||||
/// store and vendored trees all sit in the writable layer beside the bind
|
||||
/// mounts. A real container listed 94k entries — 8.6 MB of `find` output —
|
||||
/// under `/workspace` alone, which overflowed
|
||||
/// [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] and failed the staleness check
|
||||
/// on every start and stop. Pruning by name (`node_modules`, `.git`) was
|
||||
/// measured on that container and only got it to 6.3 MB: most of it was a
|
||||
/// vendored phpMyAdmin in each of eight worktrees. Depth is what bounds it.
|
||||
///
|
||||
/// **Why summarising loses nothing.** Every per-path filter in
|
||||
/// [`compute_verbatim_paths`] is vacuous below `/workspace/<x>`: dpkg owns
|
||||
/// nothing there, no [`COPY_EXCLUSIONS`] entry is there, bind mounts are
|
||||
/// exactly `/workspace/<mount_name>` (a mount name cannot hold a `/`), and the
|
||||
/// base ships an empty `/workspace`. So the verbatim set was already pruned to
|
||||
/// first-level entries; a summary just stops listing what was going to be
|
||||
/// pruned away. Migration's `tar` still archives the directory recursively.
|
||||
///
|
||||
/// **One `find` pass, aggregated by `awk`.** The listing and the sizes come
|
||||
/// from the same walk, so they cannot disagree under concurrent writes, and
|
||||
/// `-xdev` keeps it off the bind mounts (a mount point is listed, never
|
||||
/// entered). Because the walk's presence then depends on `awk`, the pipeline
|
||||
/// prints `###SUMMARISED-OK` only when `awk` succeeded, and
|
||||
/// [`check_manifest_complete`] refuses a manifest without it — a missing
|
||||
/// `/workspace` must never read as an empty one.
|
||||
///
|
||||
/// **Why the base-presence exemption is safe.** See the check in
|
||||
/// [`compute_verbatim_paths`]; it rests on the base shipping nothing under
|
||||
/// these roots, which `the_base_ships_nothing_under_a_summarised_root` pins and
|
||||
/// which is logged loudly if it ever stops being true.
|
||||
pub const SUMMARISED_ROOTS: &[&str] = &["/workspace"];
|
||||
|
||||
/// Depth below a [`SUMMARISED_ROOTS`] entry at which directories are
|
||||
/// summarised instead of listed. 1 would be just as correct for migration (the
|
||||
/// verbatim set prunes to first-level entries anyway); 2 keeps the immediate
|
||||
/// contents of each top-level folder visible in the manifest for debugging, at
|
||||
/// a few hundred lines on the container that overflowed.
|
||||
pub const SUMMARY_DEPTH: usize = 2;
|
||||
|
||||
/// Marker the summarised walk prints after a successful pass, once per root.
|
||||
const SUMMARISED_OK: &str = "SUMMARISED-OK";
|
||||
|
||||
/// [`ManifestEntry::kind`] of a summarised directory. Not a `find` `%y` letter.
|
||||
pub const SUMMARY_KIND: char = 'S';
|
||||
|
||||
/// Output budget for one manifest exec against a running container.
|
||||
///
|
||||
/// Above [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] on purpose. With
|
||||
/// [`SUMMARISED_ROOTS`] a real container's manifest is a few MB (`/usr/local`
|
||||
/// ~0.8 MB, `/var/lib` ~0.1 MB, the dpkg section a few hundred kB), but the
|
||||
/// non-summarised roots are still walked file by file and a user can install
|
||||
/// anything into `/opt`. Failing at 8 MiB cost the user every staleness check;
|
||||
/// 32 MiB keeps that rare while still bounding a container-controlled read.
|
||||
pub const MANIFEST_OUTPUT_LIMIT: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Roots holding **state a base-image swap destroys and no replay can put
|
||||
/// back**. Reported by [`unpreserved_data`], never copied.
|
||||
///
|
||||
@@ -173,16 +235,28 @@ pub const PROBE_LABEL_MIGRATION: &str = "migration";
|
||||
/// One entry from the filesystem walk.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManifestEntry {
|
||||
/// `find`'s `%y`: `f` regular, `d` directory, `l` symlink, …
|
||||
/// `find`'s `%y`: `f` regular, `d` directory, `l` symlink, … — or
|
||||
/// [`SUMMARY_KIND`] for a directory under a [`SUMMARISED_ROOTS`] entry that
|
||||
/// was not listed file by file.
|
||||
pub kind: char,
|
||||
/// `find`'s `%s`; for a summary, the sum of `%s` over every non-directory
|
||||
/// beneath it, from the same pass.
|
||||
pub size: u64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
/// A directory reported as itself, with nothing implied about content.
|
||||
/// A summary is deliberately *not* one: it stands for a subtree that holds
|
||||
/// at least one file.
|
||||
pub fn is_dir(&self) -> bool {
|
||||
self.kind == 'd'
|
||||
}
|
||||
|
||||
/// A directory whose contents were aggregated rather than listed.
|
||||
pub fn is_summary(&self) -> bool {
|
||||
self.kind == SUMMARY_KIND
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one probe run learned about an image or a running container.
|
||||
@@ -202,6 +276,9 @@ pub struct Manifest {
|
||||
pub etc_paths: BTreeSet<String>,
|
||||
/// `package -> version` for every installed dpkg package.
|
||||
pub dpkg_versions: BTreeMap<String, String>,
|
||||
/// How many [`SUMMARISED_ROOTS`] walks reported finishing. See
|
||||
/// [`check_manifest_complete`].
|
||||
pub summarised_roots_complete: usize,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -217,18 +294,26 @@ impl Manifest {
|
||||
/// Sections are separated by sentinel lines so one exec answers every question;
|
||||
/// on a 5.49 GB image the whole thing takes about three seconds. Every command
|
||||
/// is failure-tolerant (`2>/dev/null`, no `set -e`) because a missing `npm` or
|
||||
/// an unreadable directory must degrade one section, not the run.
|
||||
/// an unreadable directory must degrade one section, not the run — with one
|
||||
/// exception, the summarised walk, whose failure [`check_manifest_complete`]
|
||||
/// turns into an error because degrading it would lose data.
|
||||
pub fn manifest_script() -> String {
|
||||
let feature_paths = FEATURE_PROBES
|
||||
.iter()
|
||||
.map(|(p, _)| shell_single_quote(p))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let roots = MANIFEST_ROOTS
|
||||
let plain_roots: Vec<&str> = MANIFEST_ROOTS
|
||||
.iter()
|
||||
.map(|p| shell_single_quote(p))
|
||||
.copied()
|
||||
.filter(|p| !SUMMARISED_ROOTS.contains(p))
|
||||
.collect();
|
||||
let plain = plain_walk(&plain_roots);
|
||||
let summarised = SUMMARISED_ROOTS
|
||||
.iter()
|
||||
.map(|r| summarised_walk(r))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
.join("\necho '###PATHS'\n");
|
||||
// The dpkg grep is anchored to the manifest roots so the section stays a
|
||||
// few hundred kB instead of the ~40 MB a full ownership dump would be.
|
||||
let dpkg_filter = MANIFEST_ROOTS
|
||||
@@ -239,7 +324,9 @@ pub fn manifest_script() -> String {
|
||||
format!(
|
||||
r#"
|
||||
echo '###PATHS'
|
||||
find {roots} -xdev -printf '%y\t%s\t%p\n' 2>/dev/null
|
||||
{plain}
|
||||
echo '###PATHS'
|
||||
{summarised}
|
||||
echo '###DPKG'
|
||||
cat /var/lib/dpkg/info/*.list 2>/dev/null | grep -E '^/({dpkg_filter})(/|$)'
|
||||
echo '###APT'
|
||||
@@ -260,6 +347,90 @@ exit 0
|
||||
)
|
||||
}
|
||||
|
||||
/// A per-file `find` over `roots`, or nothing at all when there are none.
|
||||
///
|
||||
/// The empty case is not cosmetic: `find -xdev …` with no starting point walks
|
||||
/// `.`, which in a probe container is `/` — the whole filesystem, straight into
|
||||
/// an output-capped manifest.
|
||||
fn plain_walk(roots: &[&str]) -> String {
|
||||
if roots.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let roots = roots
|
||||
.iter()
|
||||
.map(|p| shell_single_quote(p))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("find {} -xdev -printf '%y\\t%s\\t%p\\n' 2>/dev/null", roots)
|
||||
}
|
||||
|
||||
/// Aggregates a per-file `find` listing of one root (`kind<TAB>size<TAB>path`)
|
||||
/// into [`SUMMARY_DEPTH`]-limited `###PATHS` lines. See [`SUMMARISED_ROOTS`].
|
||||
///
|
||||
/// Entries above the boundary are printed as they arrive; a directory *at* the
|
||||
/// boundary is held back and printed at the end — as `S` with the summed size
|
||||
/// of every non-directory beneath it, or as its own `d` line when it has none,
|
||||
/// so an empty scaffold stays empty to the filter. A deeper entry whose
|
||||
/// boundary ancestor was never seen is printed rather than dropped (it cannot
|
||||
/// happen in a pre-order walk; erring towards over-listing is the safe side).
|
||||
/// The path is everything after the second tab, so a tab in a name survives.
|
||||
/// POSIX `awk` only: the image has `mawk`.
|
||||
const SUMMARY_AWK: &str = r#"BEGIN { FS = "\t"; rl = length(root) }
|
||||
{
|
||||
kind = $1; size = $2
|
||||
p = $0; sub(/^[^\t]*\t[^\t]*\t/, "", p)
|
||||
if (p == root || substr(p, 1, rl + 1) != root "/") { print; next }
|
||||
n = split(substr(p, rl + 2), c, "/")
|
||||
if (n < depth) { print; next }
|
||||
key = root
|
||||
for (i = 1; i <= depth; i++) key = key "/" c[i]
|
||||
if (n == depth) {
|
||||
if (kind != "d") { print; next }
|
||||
order[++nb] = key; dsize[key] = size; files[key] = 0; bytes[key] = 0
|
||||
next
|
||||
}
|
||||
if (!(key in files)) { print; next }
|
||||
if (kind != "d") { files[key]++; bytes[key] += size }
|
||||
}
|
||||
END {
|
||||
for (i = 1; i <= nb; i++) {
|
||||
k = order[i]
|
||||
if (files[k] > 0) printf "S\t%.0f\t%s\n", bytes[k], k
|
||||
else printf "d\t%s\t%s\n", dsize[k], k
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// The `###PATHS` lines for one [`SUMMARISED_ROOTS`] entry, from one `find`
|
||||
/// pass piped through [`SUMMARY_AWK`], then `###SUMMARISED-OK` only if `awk`
|
||||
/// succeeded.
|
||||
fn summarised_walk(root: &str) -> String {
|
||||
format!(
|
||||
"find {q} -xdev -printf '%y\\t%s\\t%p\\n' 2>/dev/null | awk -v root={q} -v depth={d} {prog} && echo '###{ok}'",
|
||||
q = shell_single_quote(root),
|
||||
d = SUMMARY_DEPTH,
|
||||
prog = shell_single_quote(SUMMARY_AWK),
|
||||
ok = SUMMARISED_OK,
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse a manifest whose summarised walk did not finish.
|
||||
///
|
||||
/// Every other section degrades on failure, and that is right for them — a
|
||||
/// missing `npm` costs the npm delta. This one cannot: the summarised walk is
|
||||
/// the *only* record of what lives under `/workspace`, so a silently absent one
|
||||
/// reads as "nothing there" and the container swap would destroy it.
|
||||
pub fn check_manifest_complete(m: &Manifest) -> Result<(), String> {
|
||||
if m.summarised_roots_complete < SUMMARISED_ROOTS.len() {
|
||||
return Err(format!(
|
||||
"Could not list {} inside the container (the probe's `awk` step did not finish), \
|
||||
so Triple-C cannot tell what an update would need to carry across.",
|
||||
SUMMARISED_ROOTS.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Parse the output of [`manifest_script`].
|
||||
///
|
||||
/// Unknown sections and malformed lines are skipped rather than failing: the
|
||||
@@ -271,6 +442,9 @@ pub fn parse_manifest(raw: &str) -> Manifest {
|
||||
for line in raw.lines() {
|
||||
let line = line.strip_suffix('\r').unwrap_or(line);
|
||||
if let Some(name) = line.strip_prefix("###") {
|
||||
if name == SUMMARISED_OK {
|
||||
m.summarised_roots_complete += 1;
|
||||
}
|
||||
section = match name {
|
||||
"PATHS" | "DPKG" | "APT" | "NPM" | "FEATURES" | "ETC" | "PKGVER" | "END" => name,
|
||||
_ => "",
|
||||
@@ -416,7 +590,8 @@ pub fn prune_to_roots(paths: &BTreeSet<String>) -> Vec<String> {
|
||||
/// * it lives under a [`COPY_ROOTS`] entry,
|
||||
/// * it is not under a [`COPY_EXCLUSIONS`] entry or a bind-mount target,
|
||||
/// * neither image's dpkg database owns it,
|
||||
/// * the current base image does not already have it.
|
||||
/// * the current base image does not already have it (a [`SUMMARY_KIND`]
|
||||
/// entry is exempt from this one — see the comment at the check).
|
||||
///
|
||||
/// The result is then pruned to subtree roots. An empty result means the copy
|
||||
/// step is skipped entirely.
|
||||
@@ -446,7 +621,24 @@ pub fn compute_verbatim_paths(
|
||||
if from.dpkg_owned.contains(p) || base.dpkg_owned.contains(p) {
|
||||
continue;
|
||||
}
|
||||
if base_paths.contains(p) {
|
||||
// Not for a summary: "the base already has this path" is a per-path
|
||||
// judgment, and a summary hides which paths are inside it. Skipping one
|
||||
// would drop every user file under any subtree the base happens to ship
|
||||
// too. Copying it is safe — the restore's `--skip-old-files` keeps the
|
||||
// base's copy of anything both have, which is the per-file answer.
|
||||
// What it cannot do is notice base content the new base *removed*, so
|
||||
// this rests on the base shipping nothing under a summarised root —
|
||||
// pinned by `the_base_ships_nothing_under_a_summarised_root`, and said
|
||||
// out loud here if a base ever breaks it.
|
||||
if base_paths.contains(p) && entry.is_summary() {
|
||||
log::warn!(
|
||||
"The base image ships {}, which this manifest only summarises — copying the \
|
||||
container's whole subtree and relying on --skip-old-files to keep the base's \
|
||||
files. SUMMARISED_ROOTS assumes the base ships nothing there; revisit it.",
|
||||
p
|
||||
);
|
||||
}
|
||||
if base_paths.contains(p) && !entry.is_summary() {
|
||||
continue;
|
||||
}
|
||||
candidates.insert(entry.path.clone());
|
||||
@@ -1061,7 +1253,29 @@ pub async fn manifest_from_image(image: &str) -> Result<Manifest, String> {
|
||||
}
|
||||
));
|
||||
}
|
||||
Ok(parse_manifest(&out.stdout))
|
||||
let m = parse_manifest(&out.stdout);
|
||||
check_manifest_complete(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Turn a manifest exec's failure into something the user can act on.
|
||||
///
|
||||
/// The one case worth naming is the output ceiling: what reached the banner was
|
||||
/// "OUTPUT_LIMIT: Command output exceeded 8388608 bytes and was abandoned",
|
||||
/// true of a buffer and no help about a container. Same idea as
|
||||
/// `file_commands::describe_listing_failure`.
|
||||
pub fn describe_manifest_failure(error: String) -> String {
|
||||
if error.starts_with(super::exec::OUTPUT_LIMIT_MARKER) {
|
||||
return format!(
|
||||
"This container has too many files for Triple-C to inspect for an update (its \
|
||||
file listing passed {} MiB). The usual cause is a very large tree installed \
|
||||
under /usr/local, /opt, /srv, /var/lib, /var/www or /etc, or a huge number of \
|
||||
files directly in a top-level /workspace folder. Removing it, or moving it \
|
||||
into a mounted project folder, will let the check run.",
|
||||
MANIFEST_OUTPUT_LIMIT / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a *running* container.
|
||||
@@ -1071,20 +1285,23 @@ pub async fn manifest_from_image(image: &str) -> Result<Manifest, String> {
|
||||
/// installed since the last commit, and a verbatim set computed from a stale
|
||||
/// manifest would silently fail to carry that work across.
|
||||
pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, String> {
|
||||
let (out, code) = super::exec::exec_oneshot_as(
|
||||
let (out, code) = super::exec::exec_oneshot_limited(
|
||||
container_id,
|
||||
"root",
|
||||
vec!["/bin/sh".to_string(), "-c".to_string(), manifest_script()],
|
||||
Vec::new(),
|
||||
MANIFEST_OUTPUT_LIMIT,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(describe_manifest_failure)?;
|
||||
if !out.contains("###END") {
|
||||
return Err(format!(
|
||||
"Probe of the running container did not complete (exit {})",
|
||||
code
|
||||
));
|
||||
}
|
||||
Ok(parse_manifest(&out))
|
||||
let m = parse_manifest(&out);
|
||||
check_manifest_complete(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Cached stopped-container manifests, keyed by container id, each paired with
|
||||
@@ -2220,6 +2437,308 @@ mod tests {
|
||||
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
||||
}
|
||||
|
||||
// ── /workspace summarisation (the 8 MiB overflow) ───────────────────────
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_summary_carries_its_own_size_and_is_not_a_directory() {
|
||||
let raw = "###PATHS\n\
|
||||
d\t4096\t/workspace/wt\n\
|
||||
f\t10\t/workspace/wt/README.md\n\
|
||||
S\t123456\t/workspace/wt/node_modules\n\
|
||||
###SUMMARISED-OK\n\
|
||||
###DPKG\n\
|
||||
###END\n";
|
||||
let m = parse_manifest(raw);
|
||||
assert_eq!(m.paths.len(), 3);
|
||||
let s = &m.paths[2];
|
||||
assert!(s.is_summary());
|
||||
assert!(!s.is_dir(), "a summary stands for content, not an empty directory");
|
||||
assert_eq!(s.size, 123456);
|
||||
assert!(check_manifest_complete(&m).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manifest_whose_summarised_walk_did_not_finish_is_refused() {
|
||||
// If the aggregating `awk` is missing or dies, the /workspace walk is
|
||||
// simply absent. Read as "nothing there", the migration would destroy
|
||||
// the user's top-level /workspace folders — so it is an error instead.
|
||||
let raw = "###PATHS\nd\t4096\t/opt\n###DPKG\n###END\n";
|
||||
let m = parse_manifest(raw);
|
||||
let err = check_manifest_complete(&m).unwrap_err();
|
||||
assert!(err.contains("/workspace"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summarised_subtree_is_copied_whole_and_counted_in_the_payload() {
|
||||
// `/workspace/node_modules` holds nothing at its own level but deeper
|
||||
// summaries: it must not look like an empty tree and be dropped.
|
||||
let from = manifest(
|
||||
&[
|
||||
('d', 4096, "/workspace/node_modules"),
|
||||
('S', 50_000, "/workspace/node_modules/typescript"),
|
||||
('S', 7_000, "/workspace/node_modules/.bin"),
|
||||
('d', 4096, "/workspace/wt"),
|
||||
('f', 10, "/workspace/wt/README.md"),
|
||||
('S', 1_000, "/workspace/wt/src"),
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let verbatim = compute_verbatim_paths(&from, &Manifest::default(), &[]);
|
||||
assert_eq!(
|
||||
verbatim,
|
||||
vec!["/workspace/node_modules".to_string(), "/workspace/wt".to_string()]
|
||||
);
|
||||
assert_eq!(verbatim_payload_bytes(&from, &verbatim), 58_010);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summary_the_base_also_has_is_still_copied() {
|
||||
// The presence-in-base filter is a per-path judgment, and a summary
|
||||
// hides which paths are inside it. Dropping it would lose the user's
|
||||
// files under any subtree the base happens to ship; copying it is safe
|
||||
// because the restore's `--skip-old-files` never clobbers the base.
|
||||
// (Today's base ships an empty /workspace — the ignored live test
|
||||
// `the_base_ships_nothing_under_a_summarised_root` pins that.)
|
||||
let from = manifest(
|
||||
&[
|
||||
('d', 4096, "/workspace/tools"),
|
||||
('S', 900, "/workspace/tools/lib"),
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let base = manifest(
|
||||
&[
|
||||
('d', 4096, "/workspace/tools"),
|
||||
('S', 100, "/workspace/tools/lib"),
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
compute_verbatim_paths(&from, &base, &[]),
|
||||
vec!["/workspace/tools/lib".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workspace_is_walked_once_and_never_file_by_file() {
|
||||
let s = manifest_script();
|
||||
assert_eq!(
|
||||
s.matches("find '/workspace'").count(),
|
||||
1,
|
||||
"/workspace must be walked in a single pass:\n{}",
|
||||
s
|
||||
);
|
||||
assert!(!s.contains("'/workspace' '/var/lib'"), "flat walk is back:\n{}", s);
|
||||
assert!(!s.contains("du "), "sizes come from the same pass, not du:\n{}", s);
|
||||
assert!(s.contains("###SUMMARISED-OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_of_no_roots_is_no_walk_at_all() {
|
||||
// `find -xdev …` with no starting point walks `.`, which is `/` in a
|
||||
// probe container — the whole filesystem, into the manifest.
|
||||
assert_eq!(plain_walk(&[]), "");
|
||||
assert!(plain_walk(&["/opt"]).starts_with("find '/opt' -xdev"));
|
||||
}
|
||||
|
||||
/// Runs the real walk against a scratch tree shaped like the container that
|
||||
/// overflowed, then a section after it, through the real parser.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_summarised_walk_is_small_exact_and_ends_cleanly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("workspace");
|
||||
for wt in 0..4 {
|
||||
let nm = root.join(format!("wt{}", wt)).join("node_modules");
|
||||
for pkg in 0..50 {
|
||||
let p = nm.join(format!("pkg{}", pkg)).join("lib");
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
for f in 0..10 {
|
||||
std::fs::write(p.join(format!("f{}.js", f)), b"0123456789").unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::write(root.join(format!("wt{}", wt)).join("README.md"), b"hi").unwrap();
|
||||
}
|
||||
// Second-level directories holding no file at any depth: one empty,
|
||||
// one with only empty subdirectories. Neither is content.
|
||||
std::fs::create_dir_all(root.join("wt0").join("empty")).unwrap();
|
||||
std::fs::create_dir_all(root.join("scaffold").join("a").join("b").join("c")).unwrap();
|
||||
std::fs::write(root.join("loose.txt"), b"x").unwrap();
|
||||
let root_s = root.to_string_lossy().to_string();
|
||||
|
||||
let script = format!(
|
||||
"echo '###PATHS'\n{}\necho '###APT'\necho socat\necho '###END'\n",
|
||||
summarised_walk(&root_s)
|
||||
);
|
||||
let out = std::process::Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&script)
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let m = parse_manifest(&text);
|
||||
|
||||
// 2,000 files below; one line per first- and second-level entry above.
|
||||
assert!(m.paths.len() < 20, "walk was not summarised: {}", text);
|
||||
// Exactly the old per-file sum: 50 packages × 10 files × 10 bytes.
|
||||
// Directory inode sizes are not content and must not be counted.
|
||||
let nm0 = format!("{}/wt0/node_modules", root_s);
|
||||
let entry = m.paths.iter().find(|e| e.path == nm0).expect("summary present");
|
||||
assert!(entry.is_summary());
|
||||
assert_eq!(entry.size, 5_000, "{:?}", entry);
|
||||
for empty in [format!("{}/wt0/empty", root_s), format!("{}/scaffold/a", root_s)] {
|
||||
let e = m.paths.iter().find(|e| e.path == empty).expect("listed");
|
||||
assert!(e.is_dir(), "a tree with no files is not content: {:?}", e);
|
||||
}
|
||||
assert!(m.paths.iter().any(|e| e.path == format!("{}/loose.txt", root_s)));
|
||||
// The walk terminates its section: the next one still parses.
|
||||
assert_eq!(m.apt_manual, strs(&["socat"]));
|
||||
assert_eq!(m.summarised_roots_complete, 1, "{}", text);
|
||||
|
||||
// And the migration sees the same thing it would have per file.
|
||||
let from = Manifest {
|
||||
paths: m
|
||||
.paths
|
||||
.iter()
|
||||
.map(|e| ManifestEntry {
|
||||
path: e.path.replacen(&root_s, "/workspace", 1),
|
||||
..e.clone()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let verbatim = compute_verbatim_paths(&from, &Manifest::default(), &[]);
|
||||
assert!(!verbatim.contains(&"/workspace/scaffold".to_string()), "{:?}", verbatim);
|
||||
assert_eq!(
|
||||
verbatim_payload_bytes(&from, &["/workspace/wt0".to_string()]),
|
||||
5_000 + 2
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins the assumption that makes summaries safe to exempt from the
|
||||
/// presence-in-base check: the base ships nothing under a summarised root.
|
||||
/// If a future Dockerfile puts content in /workspace this fails, and
|
||||
/// `compute_verbatim_paths` needs revisiting before it ships.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture base_ships_nothing
|
||||
/// ```
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon and the base image"]
|
||||
async fn the_base_ships_nothing_under_a_summarised_root() {
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
let m = manifest_from_image(&image).await.expect("base probe");
|
||||
for root in SUMMARISED_ROOTS {
|
||||
let inside: Vec<_> = m
|
||||
.paths
|
||||
.iter()
|
||||
.filter(|e| is_under(&e.path, root) && e.path != *root)
|
||||
.map(|e| e.path.as_str())
|
||||
.collect();
|
||||
assert!(inside.is_empty(), "the base ships {:?} under {}", inside, root);
|
||||
}
|
||||
}
|
||||
|
||||
/// The overflow end to end, against a scratch container off the real base:
|
||||
/// 80k files in a writable-layer `/workspace` tree (past 8 MiB unsummarised)
|
||||
/// beside a bind-mounted project folder.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture large_workspace
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates and removes a throwaway container"]
|
||||
async fn a_large_workspace_is_probed_without_overflowing_and_still_copied() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
let host = tempfile::tempdir().unwrap();
|
||||
for i in 0..50 {
|
||||
std::fs::write(host.path().join(format!("host-{}.txt", i)), b"h").unwrap();
|
||||
}
|
||||
let mount = format!("{}:/workspace/proj", host.path().display());
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"-v", &mount,
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 300",
|
||||
]);
|
||||
// 80k files with long names, three levels down: ~9.2 MB as a flat walk.
|
||||
let populate = "for w in 0 1 2 3 4 5 6 7; do for p in $(seq 1 100); do \
|
||||
d=/workspace/worktree-number-$w/node_modules/package-with-a-long-name-$p/lib; \
|
||||
mkdir -p $d; cd $d && seq -f 'module-file-with-a-long-name-%g.js' 1 100 | xargs touch; \
|
||||
done; done; echo x > /workspace/worktree-number-0/README.md; \
|
||||
head -c 4096 /dev/zero > /workspace/worktree-number-0/node_modules/real.js";
|
||||
docker_cli(&["exec", "-u", "root", &id, "sh", "-c", populate]);
|
||||
|
||||
let result = manifest_from_container(&id).await;
|
||||
let _ = std::process::Command::new("docker").args(["rm", "-f", &id]).output();
|
||||
|
||||
let m = result.expect("the probe must not overflow");
|
||||
let ws: Vec<_> = m.paths.iter().filter(|e| is_under(&e.path, "/workspace")).collect();
|
||||
assert!(ws.len() < 100, "/workspace was walked file by file: {} entries", ws.len());
|
||||
assert!(
|
||||
!ws.iter().any(|e| is_under(&e.path, "/workspace/proj") && e.path != "/workspace/proj"),
|
||||
"the walk crossed into the bind mount"
|
||||
);
|
||||
let nm = m
|
||||
.paths
|
||||
.iter()
|
||||
.find(|e| e.path == "/workspace/worktree-number-0/node_modules")
|
||||
.expect("summary entry");
|
||||
// Every file below is empty but `real.js`: the per-file sum, exactly,
|
||||
// with no directory inode sizes in it — computed by the image's `mawk`.
|
||||
assert!(nm.is_summary() && nm.size == 4096, "{:?}", nm);
|
||||
assert_eq!(m.summarised_roots_complete, SUMMARISED_ROOTS.len());
|
||||
|
||||
let excl = bind_mount_exclusions(&[pp("proj")]);
|
||||
let verbatim = compute_verbatim_paths(&m, &Manifest::default(), &excl);
|
||||
let ws_verbatim: Vec<_> =
|
||||
verbatim.iter().filter(|p| is_under(p, "/workspace")).cloned().collect();
|
||||
assert_eq!(
|
||||
ws_verbatim,
|
||||
(0..8).map(|w| format!("/workspace/worktree-number-{}", w)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overflowing_probe_says_what_to_do_rather_than_quoting_a_buffer() {
|
||||
let raw = format!(
|
||||
"{}: Command output exceeded 33554432 bytes and was abandoned",
|
||||
crate::docker::exec::OUTPUT_LIMIT_MARKER
|
||||
);
|
||||
let msg = describe_manifest_failure(raw);
|
||||
assert!(!msg.contains(crate::docker::exec::OUTPUT_LIMIT_MARKER), "{}", msg);
|
||||
assert!(!msg.contains("33554432"), "{}", msg);
|
||||
// Every root still walked file by file, so the advice is never wrong
|
||||
// about where to look.
|
||||
for root in MANIFEST_ROOTS.iter().chain(["/etc"].iter()) {
|
||||
if !SUMMARISED_ROOTS.contains(root) {
|
||||
assert!(msg.contains(root), "message omits {}: {}", root, msg);
|
||||
}
|
||||
}
|
||||
// Anything else passes through untouched.
|
||||
assert_eq!(describe_manifest_failure("boom".into()), "boom");
|
||||
}
|
||||
|
||||
|
||||
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ca_certs;
|
||||
pub mod client;
|
||||
pub mod events;
|
||||
pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct ViewerPoll {
|
||||
@@ -40,7 +40,10 @@ pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
|
||||
ViewerPoll { exists: true, hash, size }
|
||||
}
|
||||
|
||||
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
|
||||
pub async fn poll_file(
|
||||
container: &ContainerTarget,
|
||||
container_path: &str,
|
||||
) -> Result<ViewerPoll, String> {
|
||||
let cmd = vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
@@ -48,8 +51,9 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result<Viewe
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
if code != 0 && code != 4 {
|
||||
return Err(format!(
|
||||
"Could not check the file: {}",
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
//! hold the resolved path, not the one that was clicked.
|
||||
|
||||
use crate::commands::file_commands::validate_container_path;
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
pub const MAX_CANDIDATES: usize = 16;
|
||||
const MAX_RAW_LEN: usize = 4096;
|
||||
@@ -131,13 +131,14 @@ pub fn parse_probe_output(stdout: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
pub async fn probe_candidates(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
candidates: &[String],
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
|
||||
cmd.extend(candidates.iter().cloned());
|
||||
let (stdout, _stderr, _code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, _stderr, _code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
Ok(parse_probe_output(&stdout))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::commands::file_commands::clip_container_text;
|
||||
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager};
|
||||
use crate::host::{ContainerTarget, ExecSessionManager};
|
||||
|
||||
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
|
||||
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
@@ -172,7 +172,7 @@ fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String>
|
||||
}
|
||||
|
||||
pub async fn write_file(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
exec_manager: &ExecSessionManager,
|
||||
target: &str,
|
||||
bytes: &[u8],
|
||||
@@ -181,11 +181,12 @@ pub async fn write_file(
|
||||
check_write_input(bytes.len(), base_hash)?;
|
||||
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
|
||||
let tmp_path = exec_manager
|
||||
.write_file_to_container(container_id, &tmp_name, bytes)
|
||||
.write_file_to_container(container, &tmp_name, bytes)
|
||||
.await?;
|
||||
let cmd = write_command(target, &tmp_path, base_hash);
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
saved_file(classify_write(code, &stdout, &stderr), bytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
//! The app's side of `triple-c-agent`, the small server that runs inside a
|
||||
//! project container and does over HTTP what the Docker variant does through
|
||||
//! the Docker API.
|
||||
//!
|
||||
//! Wire protocol: `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`.
|
||||
//! Its "Client mapping" table is what this file implements, one function per
|
||||
//! [`ContainerTarget`](super::ContainerTarget) method. The contract is Docker
|
||||
//! parity: every function here returns exactly what its `docker/exec.rs`
|
||||
//! counterpart returns for the same command, including the shape of one-shot
|
||||
//! output (combined in arrival order, or stdout/stderr split), the
|
||||
//! `OUTPUT_LIMIT` refusal text, and the path an upload reports.
|
||||
//!
|
||||
//! The token never leaves this file except as the `Authorization` header:
|
||||
//! [`AgentEndpoint`]'s `Debug` redacts it and no error string is built from a
|
||||
//! request (reqwest and tungstenite errors name the URL, which carries no
|
||||
//! secret, never the headers).
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncWrite;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use crate::host::OUTPUT_LIMIT_MARKER;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_util::sync::PollSender;
|
||||
|
||||
use super::{ArchiveStream, AttachedExec, ExecChunk, ExecSpec};
|
||||
use crate::docker::exec::{
|
||||
container_join, output_limit_error, parse_user_ids, read_host_file_for_upload, user_ids_cmd,
|
||||
OneshotOutput, EXEC_EXIT_POLLS, EXEC_EXIT_POLL_INTERVAL, HOST_FILE_UPLOAD_MODE,
|
||||
MAX_ONESHOT_OUTPUT, USER_IDS_OUTPUT_LIMIT,
|
||||
};
|
||||
|
||||
/// Connecting (TCP, and the WebSocket handshake) must not hang a caller on an
|
||||
/// agent that is not there. Nothing else has an overall timeout: a one-shot
|
||||
/// exec, an attach or an archive download can legitimately run for minutes.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// `is_running` is a liveness probe; it answers quickly or says "no".
|
||||
const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Whole-request ceiling for the small calls (detached start, exec status,
|
||||
/// resize) and for reading any small body. The agent is not trusted: one that
|
||||
/// accepts a connection and then says nothing must not hang the app. One-shot
|
||||
/// execs, attach and archive have *no* overall timeout — commands can
|
||||
/// legitimately run long and send nothing until they finish, as with Docker.
|
||||
#[cfg(not(test))]
|
||||
const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Short in tests, so the hostile-agent tests finish quickly.
|
||||
#[cfg(test)]
|
||||
const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Largest WebSocket message either side may send; the agent enforces the same
|
||||
/// (its output frames are at most 32 KiB). Stdin is split to fit.
|
||||
const WS_MAX_MESSAGE: usize = 1024 * 1024;
|
||||
const STDIN_FRAME_MAX: usize = 64 * 1024;
|
||||
/// How long an attach waits for the agent's `started` reply after the
|
||||
/// handshake. Spawning a process is fast; this only bounds a wedged agent.
|
||||
const ATTACH_START_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// In-flight stdin frames per attach before `poll_write` applies backpressure.
|
||||
const STDIN_QUEUE: usize = 64;
|
||||
|
||||
/// Where an agent listens and the bearer token it wants.
|
||||
///
|
||||
/// `base_url` is `http://127.0.0.1:<port>/`: the local end of an SSM
|
||||
/// port-forward on AWS, or a published loopback port in the local harness.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AgentEndpoint {
|
||||
pub base_url: url::Url,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentEndpoint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgentEndpoint")
|
||||
.field("base_url", &self.base_url.as_str())
|
||||
.field("token", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentEndpoint {
|
||||
/// An endpoint at `base_url`, which must be a plain `http://` URL with a
|
||||
/// host. A trailing `/` is added so endpoint paths join beneath it.
|
||||
pub fn new(mut base_url: url::Url, token: impl Into<String>) -> Result<Self, String> {
|
||||
if base_url.scheme() != "http" || base_url.host().is_none() {
|
||||
return Err(format!(
|
||||
"An agent endpoint must be an http:// URL with a host, not {}",
|
||||
base_url
|
||||
));
|
||||
}
|
||||
if !base_url.path().ends_with('/') {
|
||||
let path = format!("{}/", base_url.path());
|
||||
base_url.set_path(&path);
|
||||
}
|
||||
base_url.set_query(None);
|
||||
base_url.set_fragment(None);
|
||||
let token = token.into();
|
||||
// It goes in an HTTP header; refuse anything that cannot, here, once,
|
||||
// rather than failing (or silently degrading) on every request.
|
||||
if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) {
|
||||
return Err("An agent token must be non-empty printable ASCII with no spaces".to_string());
|
||||
}
|
||||
Ok(Self {
|
||||
base_url,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
/// The agent on `127.0.0.1:<port>`.
|
||||
pub fn loopback(port: u16, token: impl Into<String>) -> Self {
|
||||
let url = url::Url::parse(&format!("http://127.0.0.1:{}/", port))
|
||||
.expect("a loopback URL always parses");
|
||||
Self::new(url, token).expect("a loopback URL is a valid endpoint")
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> url::Url {
|
||||
self.base_url
|
||||
.join(path)
|
||||
.expect("endpoint paths are fixed relative paths")
|
||||
}
|
||||
|
||||
fn ws_url(&self, path: &str) -> url::Url {
|
||||
let mut u = self.url(path);
|
||||
u.set_scheme("ws").expect("http -> ws is a valid scheme change");
|
||||
u
|
||||
}
|
||||
|
||||
fn bearer(&self) -> String {
|
||||
format!("Bearer {}", self.token)
|
||||
}
|
||||
|
||||
/// The `Authorization` header, marked sensitive so it is never logged or
|
||||
/// shown by `Debug` (reqwest/hyper honour the flag).
|
||||
fn auth(&self) -> HeaderValue {
|
||||
let mut v = HeaderValue::from_str(&self.bearer())
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("Bearer invalid-token"));
|
||||
v.set_sensitive(true);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One client for every agent: connection pooling is per host, and building a
|
||||
/// client per call would redo TLS-less setup for nothing. No proxy — the agent
|
||||
/// is always on loopback, and a corporate `HTTP_PROXY` must not capture it.
|
||||
fn http() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.no_proxy()
|
||||
// The agent lives inside the container, which is not trusted with
|
||||
// the host: a redirect would let it point this client — running in
|
||||
// the host app — at any other URL. A 3xx is an error (it fails the
|
||||
// `is_success` checks like any other non-2xx).
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("a reqwest client with no TLS config always builds")
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
/// Ceiling on any small JSON body from the agent (errors, exec ids, exec
|
||||
/// status). The agent is inside the container, so a body it sends must not be
|
||||
/// able to grow host memory without bound; real ones are tens of bytes.
|
||||
const SMALL_BODY_CAP: usize = 64 * 1024;
|
||||
|
||||
/// Ceiling on error text passed through to the UI.
|
||||
const ERROR_TEXT_CAP: usize = 4 * 1024;
|
||||
|
||||
/// Parse a small JSON body, reading at most [`SMALL_BODY_CAP`] bytes.
|
||||
async fn small_json<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Option<T> {
|
||||
let raw = tokio::time::timeout(SMALL_REQUEST_TIMEOUT, read_capped(resp, SMALL_BODY_CAP))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()??;
|
||||
serde_json::from_slice(&raw).ok()
|
||||
}
|
||||
|
||||
/// The agent's `{"error": …}` text, if the body has one — bounded in both
|
||||
/// bytes read and length returned.
|
||||
async fn error_text(resp: reqwest::Response) -> Option<String> {
|
||||
let mut e = small_json::<ErrorBody>(resp).await?.error;
|
||||
if e.len() > ERROR_TEXT_CAP {
|
||||
let mut cut = ERROR_TEXT_CAP;
|
||||
while !e.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
e.truncate(cut);
|
||||
e.push('…');
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
|
||||
/// `"<context>: <why>"` for a non-success response.
|
||||
async fn status_error(context: &str, resp: reqwest::Response) -> String {
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return format!("{}: the agent rejected the credentials (401)", context);
|
||||
}
|
||||
match error_text(resp).await {
|
||||
Some(e) => format!("{}: {}", context, e),
|
||||
None => format!("{}: agent returned HTTP {}", context, status.as_u16()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(
|
||||
context: &str,
|
||||
req: reqwest::RequestBuilder,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
req.send().await.map_err(|e| format!("{}: {}", context, e))
|
||||
}
|
||||
|
||||
/// Read a response body into memory, refusing past `cap` bytes.
|
||||
async fn read_capped(mut resp: reqwest::Response, cap: usize) -> Result<Option<Vec<u8>>, String> {
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = resp
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| format!("Exec output error: {}", e))?
|
||||
{
|
||||
if body.len() + chunk.len() > cap {
|
||||
return Ok(None);
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
/// ExecSpec as the protocol spells it.
|
||||
fn spec_json(spec: &ExecSpec) -> serde_json::Map<String, serde_json::Value> {
|
||||
let v = serde_json::json!({
|
||||
"cmd": spec.cmd,
|
||||
"user": spec.user,
|
||||
"working_dir": spec.working_dir,
|
||||
"env": spec.env,
|
||||
"tty": spec.tty,
|
||||
});
|
||||
match v {
|
||||
serde_json::Value::Object(m) => m,
|
||||
_ => unreachable!("json! of an object literal is an object"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OneshotChunk {
|
||||
s: String,
|
||||
d: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OneshotResponse {
|
||||
chunks: Vec<OneshotChunk>,
|
||||
exit_code: i64,
|
||||
}
|
||||
|
||||
/// `POST /v1/exec`, reassembled into the same [`OneshotOutput`] the Docker
|
||||
/// path builds. Like Docker's one-shot, there is no tty, stdin is empty, and
|
||||
/// the working directory is the container's default.
|
||||
async fn exec_oneshot_raw(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<OneshotOutput, String> {
|
||||
const CONTEXT: &str = "Failed to start exec";
|
||||
let spec = ExecSpec::new(cmd).user(user).working_dir(None).env(env).tty(false);
|
||||
let mut body = spec_json(&spec);
|
||||
body.insert("limit".into(), serde_json::json!(limit));
|
||||
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.post(ep.url("v1/exec"))
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.json(&body),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
|
||||
// Verbatim when it is the limit message (callers match on
|
||||
// OUTPUT_LIMIT_MARKER); anything else the agent puts in a 413 is not
|
||||
// trusted to speak for it.
|
||||
return Err(error_text(resp)
|
||||
.await
|
||||
.filter(|e| e.starts_with(OUTPUT_LIMIT_MARKER))
|
||||
.unwrap_or_else(|| output_limit_error(limit)));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
|
||||
// The agent enforces `limit`, but this is container-controlled input
|
||||
// being buffered into the host, so the client bounds it too: base64 is
|
||||
// 4/3 of the payload and each chunk carries a few bytes of JSON, so a
|
||||
// body past this is over the limit however it was chunked.
|
||||
let cap = limit.saturating_mul(8).saturating_add(64 * 1024);
|
||||
let raw = read_capped(resp, cap)
|
||||
.await?
|
||||
.ok_or_else(|| output_limit_error(limit))?;
|
||||
let parsed: OneshotResponse = serde_json::from_slice(&raw)
|
||||
.map_err(|e| format!("Exec output error: malformed agent response: {}", e))?;
|
||||
|
||||
let mut out = OneshotOutput::new();
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
for chunk in parsed.chunks {
|
||||
let from_stdout = match chunk.s.as_str() {
|
||||
"out" => true,
|
||||
"err" => false,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Exec output error: unknown stream {:?} in agent response",
|
||||
other
|
||||
))
|
||||
}
|
||||
};
|
||||
let bytes = b64
|
||||
.decode(chunk.d.as_bytes())
|
||||
.map_err(|e| format!("Exec output error: bad base64 from agent: {}", e))?;
|
||||
out.push(from_stdout, &bytes, limit)?;
|
||||
}
|
||||
out.exit_code = parsed.exit_code;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Combined output and exit code under `limit` — `exec_oneshot_limited` and,
|
||||
/// with [`MAX_ONESHOT_OUTPUT`], every other combined-output variant.
|
||||
pub async fn exec_oneshot_limited(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
let out = exec_oneshot_raw(ep, user, cmd, env, limit).await?;
|
||||
Ok((out.text(), out.exit_code))
|
||||
}
|
||||
|
||||
/// `exec_oneshot_as`: combined output under [`MAX_ONESHOT_OUTPUT`].
|
||||
pub async fn exec_oneshot_as(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
exec_oneshot_limited(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
/// `exec_oneshot_streams_as`: `(stdout, stderr, exit_code)`.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
let out = exec_oneshot_raw(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
// ── detached execs, status, resize ───────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExecIdResponse {
|
||||
exec_id: String,
|
||||
}
|
||||
|
||||
pub async fn exec_detached(ep: &AgentEndpoint, spec: &ExecSpec) -> Result<String, String> {
|
||||
const CONTEXT: &str = "Failed to start exec";
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.post(ep.url("v1/exec/detached"))
|
||||
.timeout(SMALL_REQUEST_TIMEOUT)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.json(&spec_json(spec)),
|
||||
)
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
small_json::<ExecIdResponse>(resp)
|
||||
.await
|
||||
.map(|r| r.exec_id)
|
||||
.ok_or_else(|| format!("{}: malformed agent response", CONTEXT))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExecStatus {
|
||||
running: bool,
|
||||
exit_code: Option<i64>,
|
||||
}
|
||||
|
||||
/// Exit codes attach streams saw in their `exit` frame, so `wait_exec_exit`
|
||||
/// can answer without a round trip. Bounded: a long-running app opens many
|
||||
/// sessions, and the agent keeps the same answer for ten minutes anyway.
|
||||
struct ExitBook {
|
||||
codes: HashMap<(String, String), i64>,
|
||||
order: VecDeque<(String, String)>,
|
||||
}
|
||||
|
||||
const EXIT_BOOK_CAP: usize = 256;
|
||||
|
||||
fn exit_book() -> &'static Mutex<ExitBook> {
|
||||
static BOOK: OnceLock<Mutex<ExitBook>> = OnceLock::new();
|
||||
BOOK.get_or_init(|| {
|
||||
Mutex::new(ExitBook {
|
||||
codes: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn record_exit(base_url: &str, exec_id: &str, code: i64) {
|
||||
let key = (base_url.to_string(), exec_id.to_string());
|
||||
let mut book = exit_book().lock().unwrap_or_else(|p| p.into_inner());
|
||||
if book.codes.insert(key.clone(), code).is_none() {
|
||||
book.order.push_back(key);
|
||||
while book.order.len() > EXIT_BOOK_CAP {
|
||||
if let Some(old) = book.order.pop_front() {
|
||||
book.codes.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recorded_exit(base_url: &str, exec_id: &str) -> Option<i64> {
|
||||
let book = exit_book().lock().unwrap_or_else(|p| p.into_inner());
|
||||
book.codes
|
||||
.get(&(base_url.to_string(), exec_id.to_string()))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Poll `GET /v1/exec/{id}` with Docker's budget. `None` on any error, an
|
||||
/// unknown id, a finished exec with no code, or the budget running out —
|
||||
/// exactly where `docker::exec::wait_for_exec_exit` says `None`.
|
||||
pub async fn wait_exec_exit(ep: &AgentEndpoint, exec_id: &str) -> Option<i64> {
|
||||
// Docker's budget is a number of polls; bound the wall clock too, so a
|
||||
// slow-answering agent cannot stretch it (each poll is also bounded).
|
||||
let budget = EXEC_EXIT_POLL_INTERVAL * EXEC_EXIT_POLLS as u32 + SMALL_REQUEST_TIMEOUT;
|
||||
tokio::time::timeout(budget, wait_exec_exit_polls(ep, exec_id)).await.ok()?
|
||||
}
|
||||
|
||||
async fn wait_exec_exit_polls(ep: &AgentEndpoint, exec_id: &str) -> Option<i64> {
|
||||
if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) {
|
||||
return Some(code);
|
||||
}
|
||||
let url = ep.url(&format!("v1/exec/{}", urlencode_segment(exec_id)));
|
||||
for _ in 0..EXEC_EXIT_POLLS {
|
||||
if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) {
|
||||
return Some(code);
|
||||
}
|
||||
let resp = http()
|
||||
.get(url.clone())
|
||||
.timeout(SMALL_REQUEST_TIMEOUT)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let status: ExecStatus = small_json(resp).await?;
|
||||
if !status.running {
|
||||
return status.exit_code;
|
||||
}
|
||||
tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn resize_exec(ep: &AgentEndpoint, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
const CONTEXT: &str = "Failed to resize exec";
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.post(ep.url(&format!("v1/exec/{}/resize", urlencode_segment(exec_id))))
|
||||
.timeout(SMALL_REQUEST_TIMEOUT)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.json(&serde_json::json!({ "cols": cols, "rows": rows })),
|
||||
)
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exec ids come from the agent; escape them anyway so one can never turn
|
||||
/// into a different path.
|
||||
fn urlencode_segment(s: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||
}
|
||||
|
||||
// ── attach ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Ws = tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ControlFrame {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
exec_id: Option<String>,
|
||||
code: Option<i64>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// What the stdin writer hands the socket task.
|
||||
enum StdinCmd {
|
||||
Data(Bytes),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Open `GET /v1/attach` and authenticate. Shared by attach (and, later,
|
||||
/// tunnels).
|
||||
async fn connect_ws(ep: &AgentEndpoint, path: &str, context: &str) -> Result<Ws, String> {
|
||||
let mut req = ep
|
||||
.ws_url(path)
|
||||
.as_str()
|
||||
.into_client_request()
|
||||
.map_err(|e| format!("{}: {}", context, e))?;
|
||||
let auth = ep.auth();
|
||||
req.headers_mut()
|
||||
.insert(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, auth);
|
||||
let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
|
||||
.max_message_size(Some(WS_MAX_MESSAGE))
|
||||
.max_frame_size(Some(WS_MAX_MESSAGE));
|
||||
match tokio::time::timeout(
|
||||
CONNECT_TIMEOUT,
|
||||
tokio_tungstenite::connect_async_with_config(req, Some(config), false),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(_) => Err(format!("{}: timed out connecting to the agent", context)),
|
||||
Ok(Err(tokio_tungstenite::tungstenite::Error::Http(resp)))
|
||||
if resp.status() == tokio_tungstenite::tungstenite::http::StatusCode::UNAUTHORIZED =>
|
||||
{
|
||||
Err(format!("{}: the agent rejected the credentials (401)", context))
|
||||
}
|
||||
Ok(Err(e)) => Err(format!("{}: {}", context, e)),
|
||||
Ok(Ok((ws, _))) => Ok(ws),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /v1/attach`: the same [`AttachedExec`] Docker hands back.
|
||||
///
|
||||
/// Output: channel 1 → [`ExecChunk::StdOut`], 2 → [`ExecChunk::StdErr`]; the
|
||||
/// `exit` frame ends the stream and is remembered for [`wait_exec_exit`].
|
||||
/// Input: each write is one binary frame; `shutdown` sends `close_stdin`.
|
||||
/// Dropping both halves closes the socket.
|
||||
pub async fn attach(ep: &AgentEndpoint, spec: &ExecSpec) -> Result<AttachedExec, String> {
|
||||
const CONTEXT: &str = "Failed to start exec";
|
||||
let mut ws = connect_ws(ep, "v1/attach", CONTEXT).await?;
|
||||
|
||||
let mut hello = spec_json(spec);
|
||||
hello.insert("attach_stdin".into(), serde_json::json!(spec.attach_stdin));
|
||||
let hello = serde_json::Value::Object(hello).to_string();
|
||||
ws.send(Message::text(hello))
|
||||
.await
|
||||
.map_err(|e| format!("{}: {}", CONTEXT, e))?;
|
||||
|
||||
let exec_id = match tokio::time::timeout(ATTACH_START_TIMEOUT, wait_started(&mut ws)).await {
|
||||
Err(_) => return Err(format!("{}: the agent did not start the command", CONTEXT)),
|
||||
Ok(r) => r.map_err(|e| format!("{}: {}", CONTEXT, e))?,
|
||||
};
|
||||
|
||||
let (mut sink, stream) = ws.split();
|
||||
|
||||
// stdin: a bounded queue drained by one task that owns the socket's write
|
||||
// half. The task ends — dropping the write half — when the writer is
|
||||
// dropped or the socket fails.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<StdinCmd>(STDIN_QUEUE);
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
let msg = match cmd {
|
||||
StdinCmd::Data(b) => Message::binary(b),
|
||||
StdinCmd::Close => Message::text(r#"{"type":"close_stdin"}"#),
|
||||
};
|
||||
if sink.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let base_url = ep.base_url.as_str().to_string();
|
||||
let id_for_stream = exec_id.clone();
|
||||
let output = futures_util::stream::unfold(Some(stream), move |state| {
|
||||
let base_url = base_url.clone();
|
||||
let exec_id = id_for_stream.clone();
|
||||
async move {
|
||||
let mut stream = state?;
|
||||
loop {
|
||||
match stream.next().await? {
|
||||
Err(e) => return Some((Err(format!("Exec output error: {}", e)), None)),
|
||||
Ok(Message::Binary(b)) => {
|
||||
if b.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let payload = b.slice(1..);
|
||||
let chunk = match b[0] {
|
||||
1 => ExecChunk::StdOut(payload),
|
||||
2 => ExecChunk::StdErr(payload),
|
||||
_ => ExecChunk::Other(payload),
|
||||
};
|
||||
return Some((Ok(chunk), Some(stream)));
|
||||
}
|
||||
Ok(Message::Text(t)) => {
|
||||
let frame: ControlFrame = match serde_json::from_str(t.as_str()) {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
match frame.kind.as_str() {
|
||||
"exit" => {
|
||||
if let Some(code) = frame.code {
|
||||
record_exit(&base_url, &exec_id, code);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
"error" => {
|
||||
let e = frame.error.unwrap_or_else(|| "agent error".into());
|
||||
return Some((Err(format!("Exec output error: {}", e)), None));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => return None,
|
||||
Ok(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(AttachedExec {
|
||||
exec_id,
|
||||
output: Box::pin(output),
|
||||
input: Box::pin(AttachInput {
|
||||
tx: PollSender::new(tx),
|
||||
close_sent: false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read until the agent says `started` (its exec id) or `error`.
|
||||
async fn wait_started(ws: &mut Ws) -> Result<String, String> {
|
||||
while let Some(msg) = ws.next().await {
|
||||
match msg.map_err(|e| e.to_string())? {
|
||||
Message::Text(t) => {
|
||||
let frame: ControlFrame = serde_json::from_str(t.as_str())
|
||||
.map_err(|e| format!("malformed agent frame: {}", e))?;
|
||||
match frame.kind.as_str() {
|
||||
"started" => {
|
||||
return frame
|
||||
.exec_id
|
||||
.ok_or_else(|| "agent sent no exec id".to_string())
|
||||
}
|
||||
"error" => {
|
||||
return Err(frame.error.unwrap_or_else(|| "agent error".into()))
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Err("the agent closed the connection before starting the command".to_string())
|
||||
}
|
||||
|
||||
/// An attach's stdin.
|
||||
struct AttachInput {
|
||||
tx: PollSender<StdinCmd>,
|
||||
close_sent: bool,
|
||||
}
|
||||
|
||||
fn broken_pipe() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::BrokenPipe, "agent connection closed")
|
||||
}
|
||||
|
||||
impl AsyncWrite for AttachInput {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
if buf.is_empty() {
|
||||
return Poll::Ready(Ok(0));
|
||||
}
|
||||
if this.close_sent {
|
||||
return Poll::Ready(Err(broken_pipe()));
|
||||
}
|
||||
match this.tx.poll_reserve(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())),
|
||||
Poll::Ready(Ok(())) => {
|
||||
// One frame per write, at most STDIN_FRAME_MAX; `write_all`
|
||||
// loops for the rest. Keeps every frame under the agent's
|
||||
// WebSocket message limit.
|
||||
let n = buf.len().min(STDIN_FRAME_MAX);
|
||||
this.tx
|
||||
.send_item(StdinCmd::Data(Bytes::copy_from_slice(&buf[..n])))
|
||||
.map_err(|_| broken_pipe())?;
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames are flushed by the socket task as it sends them.
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
/// EOF on the child's stdin: `{"type":"close_stdin"}`, once.
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
if this.close_sent {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
match this.tx.poll_reserve(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())),
|
||||
Poll::Ready(Ok(())) => {
|
||||
this.tx.send_item(StdinCmd::Close).map_err(|_| broken_pipe())?;
|
||||
this.close_sent = true;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `(uid, gid)` of `claude`, falling back to `(1000, 1000)` — the same probe
|
||||
/// and parse as Docker's `container_user_ids`.
|
||||
pub async fn user_ids(ep: &AgentEndpoint) -> (u64, u64) {
|
||||
let (out, _) = exec_oneshot_limited(ep, "claude", user_ids_cmd(), Vec::new(), USER_IDS_OUTPUT_LIMIT)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
parse_user_ids(&out)
|
||||
}
|
||||
|
||||
/// Split Docker's tar-entry `name` (which may carry a relative directory, as
|
||||
/// the terminal drop's `triple-c-drops/<file>` does) into the `dir` and
|
||||
/// single-component `name` `PUT /v1/files` takes. Docker's extractor would
|
||||
/// put the file at the same place.
|
||||
fn split_upload_target(dest_dir: &str, file_name: &str) -> Result<(String, String), String> {
|
||||
let parts: Vec<&str> = file_name.split('/').filter(|p| !p.is_empty()).collect();
|
||||
let Some((name, parents)) = parts.split_last() else {
|
||||
return Err(format!("Failed to upload file to container: empty file name {:?}", file_name));
|
||||
};
|
||||
if parts.iter().any(|p| *p == "." || *p == "..") {
|
||||
return Err(format!(
|
||||
"Failed to upload file to container: {:?} is not a plain relative name",
|
||||
file_name
|
||||
));
|
||||
}
|
||||
let mut dir = dest_dir.trim_end_matches('/').to_string();
|
||||
for p in parents {
|
||||
dir.push('/');
|
||||
dir.push_str(p);
|
||||
}
|
||||
if dir.is_empty() {
|
||||
dir.push('/');
|
||||
}
|
||||
Ok((dir, name.to_string()))
|
||||
}
|
||||
|
||||
async fn put_file(
|
||||
ep: &AgentEndpoint,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: Vec<u8>,
|
||||
mode: u32,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<(), String> {
|
||||
const CONTEXT: &str = "Failed to upload file to container";
|
||||
let (dir, name) = split_upload_target(dest_dir, file_name)?;
|
||||
let mut url = ep.url("v1/files");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("dir", &dir)
|
||||
.append_pair("name", &name)
|
||||
.append_pair("mode", &format!("{:o}", mode))
|
||||
.append_pair("uid", &uid.to_string())
|
||||
.append_pair("gid", &gid.to_string());
|
||||
// Generous, but finite: a minute plus 256 KiB/s for the payload.
|
||||
let timeout = Duration::from_secs(60 + (data.len() as u64) / (256 * 1024));
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.put(url)
|
||||
.timeout(timeout)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(data),
|
||||
)
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Docker's `upload_bytes_with_ids`: same reported path.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
ep: &AgentEndpoint,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
put_file(ep, dest_dir, file_name, data.to_vec(), mode, ids).await?;
|
||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||
}
|
||||
|
||||
/// Docker's `upload_host_file_with_ids`: the host file is read (and checked)
|
||||
/// by the same function, mode 0644, same reported path.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
ep: &AgentEndpoint,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let path = host_path.to_string();
|
||||
let data = tokio::task::spawn_blocking(move || read_host_file_for_upload(&path))
|
||||
.await
|
||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||
put_file(ep, dest_dir, dest_name, data, HOST_FILE_UPLOAD_MODE, ids).await?;
|
||||
Ok(container_join(dest_dir, dest_name))
|
||||
}
|
||||
|
||||
/// `GET /v1/archive`, streamed. Dropping the stream drops the response,
|
||||
/// which closes the connection and cancels the read on the agent.
|
||||
pub fn download_archive(ep: &AgentEndpoint, path: &str) -> ArchiveStream {
|
||||
const CONTEXT: &str = "Failed to download archive";
|
||||
let mut url = ep.url("v1/archive");
|
||||
url.query_pairs_mut().append_pair("path", path);
|
||||
let req = http()
|
||||
.get(url)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth());
|
||||
|
||||
enum State {
|
||||
Start(reqwest::RequestBuilder),
|
||||
Body(reqwest::Response),
|
||||
}
|
||||
Box::pin(futures_util::stream::unfold(
|
||||
Some(State::Start(req)),
|
||||
|state| async move {
|
||||
let mut resp = match state? {
|
||||
State::Start(req) => match req.send().await {
|
||||
Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)),
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
return Some((Err(status_error(CONTEXT, r).await), None))
|
||||
}
|
||||
Ok(r) => r,
|
||||
},
|
||||
State::Body(r) => r,
|
||||
};
|
||||
loop {
|
||||
match resp.chunk().await {
|
||||
Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)),
|
||||
Ok(None) => return None,
|
||||
Ok(Some(b)) if b.is_empty() => continue,
|
||||
Ok(Some(b)) => return Some((Ok(b), Some(State::Body(resp)))),
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `GET /v1/health` answers → `true`. Can't connect (or times out) → `false`,
|
||||
/// the same "not running" an inspect failure gives on Docker. An agent that
|
||||
/// answers with an error status is reachable but unusable, which is an
|
||||
/// `Err` rather than a quiet `false`.
|
||||
pub async fn is_running(ep: &AgentEndpoint) -> Result<bool, String> {
|
||||
let resp = match http()
|
||||
.get(ep.url("v1/health"))
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.timeout(HEALTH_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error("Agent health check failed", resp).await);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The real agent against `docker exec`, in one real container: every
|
||||
//! operation goes through `ContainerTarget::Docker` and
|
||||
//! `ContainerTarget::Agent` on the *same* container and must come back the
|
||||
//! same. That is the agent protocol's whole requirement ("Docker parity is the
|
||||
//! requirement"), checked end to end rather than against a fake.
|
||||
//!
|
||||
//! Needs a Docker daemon and an image whose entrypoint starts the agent:
|
||||
//! `TRIPLEC_AGENT_E2E_IMAGE`, default `tc-agent-e2e:latest` (the sandbox image
|
||||
//! plus this branch's `entrypoint.sh` and `triple-c-agent`).
|
||||
|
||||
use super::*;
|
||||
use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
|
||||
use futures_util::StreamExt;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = Command::new("docker").args(args).output().expect("docker cli");
|
||||
assert!(out.status.success(), "docker {:?}: {}", args, String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn sh(script: &str) -> Vec<String> {
|
||||
vec!["sh".into(), "-c".into(), script.into()]
|
||||
}
|
||||
|
||||
/// Drain an attached exec: (stdout, stderr).
|
||||
async fn drain(exec: crate::host::AttachedExec) -> (String, String) {
|
||||
let mut out = exec.output;
|
||||
let (mut o, mut e) = (Vec::new(), Vec::new());
|
||||
let collect = async {
|
||||
while let Some(chunk) = out.next().await {
|
||||
match chunk {
|
||||
Ok(ExecChunk::StdOut(b)) => o.extend_from_slice(&b),
|
||||
Ok(ExecChunk::StdErr(b)) => e.extend_from_slice(&b),
|
||||
Ok(ExecChunk::Other(_)) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(20), collect).await.expect("attach output ended");
|
||||
(String::from_utf8_lossy(&o).into_owned(), String::from_utf8_lossy(&e).into_owned())
|
||||
}
|
||||
|
||||
/// Fail with both answers side by side.
|
||||
fn same<T: PartialEq + std::fmt::Debug>(what: &str, docker: T, agent: T) -> Result<(), String> {
|
||||
if docker == agent {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{what} differs\n docker: {docker:?}\n agent: {agent:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn parity(d: &ContainerTarget, a: &ContainerTarget) -> Result<(), String> {
|
||||
// ── one-shot ─────────────────────────────────────────────────────────────
|
||||
let script = sh("echo out1; echo err1 >&2; echo out2; exit 3");
|
||||
same(
|
||||
"streams + exit code",
|
||||
d.exec_oneshot_streams_as("claude", script.clone(), vec![]).await?,
|
||||
a.exec_oneshot_streams_as("claude", script, vec![]).await?,
|
||||
)?;
|
||||
|
||||
for user in ["claude", "root"] {
|
||||
let env = sh("env | sort");
|
||||
let strip = |s: String| -> String {
|
||||
// `docker exec` inherits the container's configured env, which in
|
||||
// this harness carries the agent's settings (the token included —
|
||||
// the local-harness trade-off entrypoint.sh documents). The agent
|
||||
// never passes its own settings on.
|
||||
s.lines().filter(|l| !l.starts_with("TRIPLEC_AGENT_")).collect::<Vec<_>>().join("\n")
|
||||
};
|
||||
let (de, dc) = d.exec_oneshot_as(user, env.clone(), vec![]).await?;
|
||||
let (ae, ac) = a.exec_oneshot_as(user, env, vec![]).await?;
|
||||
same(&format!("environment as {user}"), (strip(de), dc), (strip(ae), ac))?;
|
||||
}
|
||||
|
||||
same("default working dir", d.exec_oneshot(vec!["pwd".into()]).await?, a.exec_oneshot(vec!["pwd".into()]).await?)?;
|
||||
|
||||
let probe = sh("echo \"$FOO|$HOME|$USER|$(id -un)\"");
|
||||
let env = vec!["FOO=bar".to_string(), "HOME=/tmp".to_string()];
|
||||
same(
|
||||
"env overrides",
|
||||
d.exec_oneshot_env_status(probe.clone(), env.clone()).await?,
|
||||
a.exec_oneshot_env_status(probe, env).await?,
|
||||
)?;
|
||||
|
||||
same("user ids", d.user_ids().await, a.user_ids().await)?;
|
||||
|
||||
// Signal state: an exec starts with nothing ignored or blocked, whatever
|
||||
// the process that launched it had (the entrypoint's supervisor ignores
|
||||
// SIGHUP; that must not turn every exec into `nohup`).
|
||||
let sigs = sh("grep -E '^Sig(Ign|Blk):' /proc/self/status");
|
||||
same("signal dispositions/mask", d.exec_oneshot(sigs.clone()).await?, a.exec_oneshot(sigs).await?)?;
|
||||
|
||||
// Stdio belongs to the exec user, as runc arranges: re-opening it by
|
||||
// name works for a non-root user.
|
||||
let reopen = sh("echo out > /dev/stdout; echo err > /dev/stderr");
|
||||
same(
|
||||
"re-opening stdio by name as claude",
|
||||
d.exec_oneshot_streams_as("claude", reopen.clone(), vec![]).await?,
|
||||
a.exec_oneshot_streams_as("claude", reopen, vec![]).await?,
|
||||
)?;
|
||||
|
||||
let flood = sh("head -c 100000 /dev/zero");
|
||||
let de = d.exec_oneshot_limited("claude", flood.clone(), 1000).await.unwrap_err();
|
||||
let ae = a.exec_oneshot_limited("claude", flood, 1000).await.unwrap_err();
|
||||
if !de.starts_with(OUTPUT_LIMIT_MARKER) {
|
||||
return Err(format!("docker limit error: {de}"));
|
||||
}
|
||||
same("output limit error", de, ae)?;
|
||||
|
||||
let (_, dcode) = d.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?;
|
||||
let (_, acode) = a.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?;
|
||||
same("missing binary exit code", dcode, acode)?;
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────
|
||||
let ids = d.user_ids().await;
|
||||
d.upload_bytes_with_ids("/tmp", "via-docker.txt", b"hello\n", 0o640, ids).await?;
|
||||
a.upload_bytes_with_ids("/tmp", "via-agent.txt", b"hello\n", 0o640, ids).await?;
|
||||
let stat = |n: &str| sh(&format!("stat -c '%a %u %g %s' /tmp/{n} && cat /tmp/{n}"));
|
||||
same(
|
||||
"uploaded file (mode, owner, size, content)",
|
||||
d.exec_oneshot(stat("via-docker.txt")).await?,
|
||||
a.exec_oneshot(stat("via-agent.txt")).await?,
|
||||
)?;
|
||||
|
||||
d.exec_oneshot(sh("rm -rf /tmp/arch && mkdir -p /tmp/arch/sub && echo x > /tmp/arch/x && echo y > /tmp/arch/sub/y")).await?;
|
||||
let list = |t: &ContainerTarget| {
|
||||
let mut s = t.download_archive("/tmp/arch");
|
||||
async move {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(c) = s.next().await {
|
||||
bytes.extend_from_slice(&c?);
|
||||
}
|
||||
let mut names: Vec<(String, Vec<u8>)> = tar::Archive::new(&bytes[..])
|
||||
.entries()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|e| {
|
||||
let mut e = e.unwrap();
|
||||
let name = e.path().unwrap().to_string_lossy().trim_end_matches('/').to_string();
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(&mut e, &mut data).unwrap();
|
||||
(name, data)
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
Ok::<_, String>(names)
|
||||
}
|
||||
};
|
||||
same("archive members and contents", list(d).await?, list(a).await?)?;
|
||||
|
||||
// ── attached ─────────────────────────────────────────────────────────────
|
||||
for (label, t) in [("docker", d), ("agent", a)] {
|
||||
let mut exec = t.attach_exec(vec!["head".into(), "-n1".into()], false).await?;
|
||||
exec.input.write_all(b"ping\n").await.map_err(|e| e.to_string())?;
|
||||
exec.input.flush().await.map_err(|e| e.to_string())?;
|
||||
let id = exec.exec_id.clone();
|
||||
let (o, e) = drain(exec).await;
|
||||
same(&format!("{label}: attach echo"), ("ping\n".to_string(), String::new()), (o, e))?;
|
||||
same(&format!("{label}: attach exit"), Some(0), t.wait_exec_exit(&id).await)?;
|
||||
}
|
||||
|
||||
let mut sizes = Vec::new();
|
||||
for t in [d, a] {
|
||||
let exec = t
|
||||
.attach(ExecSpec::new(sh("sleep 1; stty size")).tty(true))
|
||||
.await?;
|
||||
t.resize_exec(&exec.exec_id, 100, 40).await?;
|
||||
let (o, _) = drain(exec).await;
|
||||
sizes.push(o.trim().to_string());
|
||||
}
|
||||
same("tty size after resize", sizes[0].clone(), sizes[1].clone())?;
|
||||
same("tty size value", "40 100".to_string(), sizes[1].clone())?;
|
||||
|
||||
// A tty session whose client goes away ends (SIGHUP), as a dropped
|
||||
// `docker exec -t` does. Only checked through the agent: it is the agent's
|
||||
// disconnect handling under test. Nothing else in this fresh container
|
||||
// runs `sleep 600`, so counting exact matches is the probe.
|
||||
let exec = a.attach(ExecSpec::new(vec!["sleep".into(), "600".into()]).tty(true)).await?;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let count = sh("pgrep -xfc 'sleep 600' || true");
|
||||
let before = a.exec_oneshot(count.clone()).await?;
|
||||
drop(exec);
|
||||
let mut after = String::new();
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
after = a.exec_oneshot(count.clone()).await?;
|
||||
if after.trim() == "0" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
same("tty session ends when its client goes away (sleep count before → after)", ("1".to_string(), "0".to_string()), (before.trim().to_string(), after.trim().to_string()))?;
|
||||
|
||||
// ── detached ─────────────────────────────────────────────────────────────
|
||||
let mut codes = Vec::new();
|
||||
for t in [d, a] {
|
||||
let id = t.exec_detached(ExecSpec::new(sh("sleep 0.3; exit 5"))).await?;
|
||||
codes.push(t.wait_exec_exit(&id).await);
|
||||
}
|
||||
same("detached exit code", codes[0], codes[1])?;
|
||||
|
||||
same("is_running", d.is_running().await?, a.is_running().await?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon and an image whose entrypoint starts triple-c-agent (TRIPLEC_AGENT_E2E_IMAGE)"]
|
||||
async fn the_agent_answers_exactly_as_docker_exec_does() {
|
||||
let image = std::env::var("TRIPLEC_AGENT_E2E_IMAGE").unwrap_or_else(|_| "tc-agent-e2e:latest".into());
|
||||
let token = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple());
|
||||
let name = format!("tc-agent-e2e-{}", uuid::Uuid::new_v4().simple());
|
||||
docker_cli(&[
|
||||
"run", "-d", "--name", &name, "-w", "/workspace",
|
||||
"-e", &format!("TRIPLEC_AGENT_TOKEN={token}"),
|
||||
// Docker forwards a published port to the container's own address,
|
||||
// not its loopback, so the harness binds the agent wide *inside* the
|
||||
// container and publishes it to the host's loopback only.
|
||||
"-e", "TRIPLEC_AGENT_ADDR=0.0.0.0:47900",
|
||||
"-p", "127.0.0.1::47900",
|
||||
&image,
|
||||
]);
|
||||
let outcome = async {
|
||||
let mapped = docker_cli(&["port", &name, "47900/tcp"]);
|
||||
let port: u16 = mapped.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.parse().ok())
|
||||
.ok_or_else(|| format!("no published port in {mapped:?}"))?;
|
||||
// Two ways in, because where *this test* runs decides which one routes:
|
||||
// on the host (where the app runs) the published loopback port; from
|
||||
// inside another container on the same daemon, the published port is
|
||||
// on the host's loopback, not ours, but the container's bridge address
|
||||
// is reachable.
|
||||
let ip = docker_cli(&["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", &name]);
|
||||
let mut candidates = vec![AgentEndpoint::loopback(port, token.clone())];
|
||||
for addr in ip.split_whitespace() {
|
||||
let url = url::Url::parse(&format!("http://{addr}:47900/")).map_err(|e| e.to_string())?;
|
||||
candidates.push(AgentEndpoint::new(url, token.clone())?);
|
||||
}
|
||||
let docker = ContainerTarget::docker(name.clone());
|
||||
|
||||
// The entrypoint starts the agent last, after setup (including a
|
||||
// bounded `claude update`), so health is the readiness signal.
|
||||
let mut agent = None;
|
||||
'wait: for _ in 0..240 {
|
||||
for ep in &candidates {
|
||||
let t = ContainerTarget::Agent(ep.clone());
|
||||
if t.is_running().await.unwrap_or(false) {
|
||||
agent = Some(t);
|
||||
break 'wait;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
let Some(agent) = agent else {
|
||||
return Err(format!("agent never answered on {candidates:?}; logs:\n{}", docker_cli(&["logs", "--tail", "40", &name])));
|
||||
};
|
||||
eprintln!("agent reached at {}", agent.id());
|
||||
parity(&docker, &agent).await
|
||||
}
|
||||
.await;
|
||||
let logs = docker_cli(&["logs", "--tail", "20", &name]);
|
||||
let _ = Command::new("docker").args(["rm", "-f", &name]).output();
|
||||
if let Err(e) = outcome {
|
||||
panic!("{e}\n--- container log tail ---\n{logs}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//! The agent client against a **fake** agent: a few axum handlers that speak
|
||||
//! just enough of the v1 protocol to exercise each client path, and record
|
||||
//! what they were sent. It is not the real server (that lives in `agent/`)
|
||||
//! and does not try to behave like one beyond the wire shapes.
|
||||
|
||||
use super::*;
|
||||
use crate::host::{ContainerTarget, OUTPUT_LIMIT_MARKER};
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Obviously fake; long enough to be unmistakable in an error string.
|
||||
const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Seen {
|
||||
auth: Vec<String>,
|
||||
exec_bodies: Vec<Value>,
|
||||
detached_bodies: Vec<Value>,
|
||||
status_polls: u32,
|
||||
resizes: Vec<(String, Value)>,
|
||||
uploads: Vec<(HashMap<String, String>, Vec<u8>)>,
|
||||
attach_hello: Option<Value>,
|
||||
stdin: Vec<u8>,
|
||||
close_stdin: bool,
|
||||
}
|
||||
|
||||
struct Fake {
|
||||
seen: std::sync::Mutex<Seen>,
|
||||
}
|
||||
|
||||
type St = State<Arc<Fake>>;
|
||||
|
||||
impl Fake {
|
||||
fn authed(&self, h: &HeaderMap) -> bool {
|
||||
let got = h
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let ok = got == format!("Bearer {}", TOKEN);
|
||||
self.seen.lock().unwrap().auth.push(got);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
async fn exec(State(f): St, h: HeaderMap, Json(body): Json<Value>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
f.seen.lock().unwrap().exec_bodies.push(body.clone());
|
||||
match body["cmd"][0].as_str().unwrap_or("") {
|
||||
"sh" => Json(json!({"chunks": [{"s": "out", "d": b64(b"1000\n1001\n")}], "exit_code": 0}))
|
||||
.into_response(),
|
||||
// stdout and stderr interleaved; `ü` split across two stdout chunks;
|
||||
// one byte that is not UTF-8 at all on stderr.
|
||||
"mixed" => Json(json!({
|
||||
"chunks": [
|
||||
{"s": "out", "d": b64(b"first ")},
|
||||
{"s": "err", "d": b64(b"find: /x: denied\n")},
|
||||
{"s": "out", "d": b64(&[0xc3])},
|
||||
{"s": "out", "d": b64(&[0xbc, b'!'])},
|
||||
{"s": "err", "d": b64(&[0xff])},
|
||||
],
|
||||
"exit_code": 3
|
||||
}))
|
||||
.into_response(),
|
||||
"big" => (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(json!({"error": "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned"})),
|
||||
)
|
||||
.into_response(),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "boom"}))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn detached(State(f): St, h: HeaderMap, Json(body): Json<Value>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
f.seen.lock().unwrap().detached_bodies.push(body);
|
||||
Json(json!({"exec_id": "d1"})).into_response()
|
||||
}
|
||||
|
||||
/// `d1` reports running for two polls, then exit 7. Everything else is 404 —
|
||||
/// including the attach's `a1`, so an attach's exit can only have come from
|
||||
/// the client's own record of the `exit` frame.
|
||||
async fn status(State(f): St, h: HeaderMap, Path(id): Path<String>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
if id != "d1" {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "no such exec"}))).into_response();
|
||||
}
|
||||
let mut s = f.seen.lock().unwrap();
|
||||
s.status_polls += 1;
|
||||
if s.status_polls < 3 {
|
||||
Json(json!({"running": true, "exit_code": null})).into_response()
|
||||
} else {
|
||||
Json(json!({"running": false, "exit_code": 7})).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn resize(State(f): St, h: HeaderMap, Path(id): Path<String>, Json(body): Json<Value>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
f.seen.lock().unwrap().resizes.push((id, body));
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn files(
|
||||
State(f): St,
|
||||
h: HeaderMap,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
let path = format!("{}/{}", q["dir"], q["name"]);
|
||||
f.seen.lock().unwrap().uploads.push((q, body.to_vec()));
|
||||
Json(json!({"path": path})).into_response()
|
||||
}
|
||||
|
||||
async fn archive(State(f): St, h: HeaderMap, Query(q): Query<HashMap<String, String>>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
if q["path"] == "/missing" {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "no such path"}))).into_response();
|
||||
}
|
||||
let parts: Vec<Result<Bytes, std::io::Error>> = vec![
|
||||
Ok(Bytes::from_static(b"tar-part-1;")),
|
||||
Ok(Bytes::from_static(b"tar-part-2;")),
|
||||
Ok(Bytes::from(q["path"].clone().into_bytes())),
|
||||
];
|
||||
axum::body::Body::from_stream(futures_util::stream::iter(parts)).into_response()
|
||||
}
|
||||
|
||||
async fn health(State(f): St, h: HeaderMap) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
Json(json!({"agent": "triple-c-agent", "version": "0.0.0-fake", "protocol": 1})).into_response()
|
||||
}
|
||||
|
||||
async fn attach_ws(State(f): St, h: HeaderMap, ws: WebSocketUpgrade) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
ws.on_upgrade(move |socket| fake_attach(f, socket))
|
||||
}
|
||||
|
||||
/// started → stdout, stderr, an unknown channel → collect stdin until
|
||||
/// `close_stdin` → echo it on stdout → exit 5 → close.
|
||||
async fn fake_attach(f: Arc<Fake>, mut ws: WebSocket) {
|
||||
let Some(Ok(WsMessage::Text(hello))) = ws.recv().await else { return };
|
||||
let hello: Value = serde_json::from_str(hello.as_str()).unwrap();
|
||||
let refuse = hello["cmd"][0] == "nope";
|
||||
f.seen.lock().unwrap().attach_hello = Some(hello);
|
||||
if refuse {
|
||||
let _ = ws
|
||||
.send(WsMessage::Text(r#"{"type":"error","error":"no such user"}"#.into()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let _ = ws
|
||||
.send(WsMessage::Text(r#"{"type":"started","exec_id":"a1"}"#.into()))
|
||||
.await;
|
||||
for frame in [&b"\x01out"[..], b"\x02err", b"\x09odd"] {
|
||||
let _ = ws.send(WsMessage::Binary(Bytes::copy_from_slice(frame))).await;
|
||||
}
|
||||
while let Some(Ok(msg)) = ws.recv().await {
|
||||
match msg {
|
||||
WsMessage::Binary(b) => f.seen.lock().unwrap().stdin.extend_from_slice(&b),
|
||||
WsMessage::Text(t) if t.as_str().contains("close_stdin") => {
|
||||
f.seen.lock().unwrap().close_stdin = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut echo = vec![1u8];
|
||||
echo.extend_from_slice(&f.seen.lock().unwrap().stdin);
|
||||
let _ = ws.send(WsMessage::Binary(echo.into())).await;
|
||||
let _ = ws.send(WsMessage::Text(r#"{"type":"exit","code":5}"#.into())).await;
|
||||
let _ = ws.close().await;
|
||||
}
|
||||
|
||||
async fn fake_agent() -> (AgentEndpoint, Arc<Fake>) {
|
||||
let fake = Arc::new(Fake {
|
||||
seen: std::sync::Mutex::new(Seen::default()),
|
||||
});
|
||||
let app = Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
.route("/v1/exec", post(exec))
|
||||
.route("/v1/exec/detached", post(detached))
|
||||
.route("/v1/exec/{id}", get(status))
|
||||
.route("/v1/exec/{id}/resize", post(resize))
|
||||
.route("/v1/files", put(files))
|
||||
.route("/v1/archive", get(archive))
|
||||
.route("/v1/attach", get(attach_ws))
|
||||
.with_state(fake.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(AgentEndpoint::loopback(port, TOKEN), fake)
|
||||
}
|
||||
|
||||
fn target(ep: &AgentEndpoint) -> ContainerTarget {
|
||||
ContainerTarget::Agent(ep.clone())
|
||||
}
|
||||
|
||||
fn cmd(c: &str) -> Vec<String> {
|
||||
vec![c.to_string()]
|
||||
}
|
||||
|
||||
// ── endpoint ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn the_token_never_appears_in_debug_output() {
|
||||
let ep = AgentEndpoint::loopback(47900, TOKEN);
|
||||
let dbg = format!("{:?} {:?}", ep, ContainerTarget::Agent(ep.clone()));
|
||||
assert!(!dbg.contains(TOKEN), "{}", dbg);
|
||||
assert!(dbg.contains("127.0.0.1:47900"));
|
||||
assert!(dbg.contains("redacted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_target_is_identified_by_its_base_url() {
|
||||
let a = ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN));
|
||||
assert_eq!(a.id(), "http://127.0.0.1:47900/");
|
||||
assert_eq!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN)));
|
||||
assert_ne!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47901, TOKEN)));
|
||||
let mut set = std::collections::HashSet::new();
|
||||
set.insert(a.clone());
|
||||
assert!(set.contains(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_plain_http_endpoints_are_accepted() {
|
||||
let ok = AgentEndpoint::new(url::Url::parse("http://127.0.0.1:1/x").unwrap(), TOKEN).unwrap();
|
||||
assert_eq!(ok.base_url.as_str(), "http://127.0.0.1:1/x/");
|
||||
assert_eq!(ok.url("v1/health").as_str(), "http://127.0.0.1:1/x/v1/health");
|
||||
assert_eq!(ok.ws_url("v1/attach").as_str(), "ws://127.0.0.1:1/x/v1/attach");
|
||||
assert!(AgentEndpoint::new(url::Url::parse("https://h/").unwrap(), TOKEN).is_err());
|
||||
assert!(AgentEndpoint::new(url::Url::parse("file:///tmp").unwrap(), TOKEN).is_err());
|
||||
}
|
||||
|
||||
// ── auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_request_carries_the_bearer_token() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
assert!(t.is_running().await.unwrap());
|
||||
t.exec_oneshot(cmd("sh")).await.unwrap();
|
||||
let auth = fake.seen.lock().unwrap().auth.clone();
|
||||
assert_eq!(auth.len(), 2);
|
||||
assert!(auth.iter().all(|a| a == &format!("Bearer {}", TOKEN)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_token_is_an_error_that_does_not_carry_any_token() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let wrong_token = "wrong-fake-token-for-unit-tests-only-1111";
|
||||
let bad = ContainerTarget::Agent(AgentEndpoint::new(ep.base_url.clone(), wrong_token).unwrap());
|
||||
|
||||
let e = bad.exec_oneshot(cmd("sh")).await.unwrap_err();
|
||||
assert!(e.contains("401"), "{}", e);
|
||||
assert!(e.starts_with("Failed to start exec"), "{}", e);
|
||||
let Err(attach_err) = bad.attach(ExecSpec::new(cmd("sh"))).await else {
|
||||
panic!("attach with a wrong token succeeded")
|
||||
};
|
||||
assert!(attach_err.contains("401"), "{}", attach_err);
|
||||
let health_err = bad.is_running().await.unwrap_err();
|
||||
let up_err = bad.upload_bytes("/tmp", "x", b"x", 0o644).await.unwrap_err();
|
||||
for e in [&e, &attach_err, &health_err, &up_err] {
|
||||
assert!(!e.contains(wrong_token) && !e.contains(TOKEN), "{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_errors_carry_the_agents_message_and_no_token() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let e = target(&ep).exec_oneshot(cmd("fail")).await.unwrap_err();
|
||||
assert_eq!(e, "Failed to start exec: boom");
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_oneshot_variant_reassembles_what_docker_would_return() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
// Arrival order, `ü` rejoined across chunks, the stray 0xff lossy.
|
||||
let combined = "first find: /x: denied\nü!\u{fffd}";
|
||||
let env = vec!["K=V".to_string()];
|
||||
|
||||
assert_eq!(t.exec_oneshot(cmd("mixed")).await.unwrap(), combined);
|
||||
assert_eq!(t.exec_oneshot_env(cmd("mixed"), env.clone()).await.unwrap(), combined);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_env_status(cmd("mixed"), env.clone()).await.unwrap(),
|
||||
(combined.to_string(), 3)
|
||||
);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_as("root", cmd("mixed"), env.clone()).await.unwrap(),
|
||||
(combined.to_string(), 3)
|
||||
);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_limited("root", cmd("mixed"), 4096).await.unwrap(),
|
||||
(combined.to_string(), 3)
|
||||
);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_streams_as("root", cmd("mixed"), env.clone()).await.unwrap(),
|
||||
("first ü!".to_string(), "find: /x: denied\n\u{fffd}".to_string(), 3)
|
||||
);
|
||||
|
||||
let bodies = fake.seen.lock().unwrap().exec_bodies.clone();
|
||||
let max = MAX_ONESHOT_OUTPUT as u64;
|
||||
let expect = [
|
||||
("claude", json!([]), max),
|
||||
("claude", json!(["K=V"]), max),
|
||||
("claude", json!(["K=V"]), max),
|
||||
("root", json!(["K=V"]), max),
|
||||
("root", json!([]), 4096),
|
||||
("root", json!(["K=V"]), max),
|
||||
];
|
||||
assert_eq!(bodies.len(), expect.len());
|
||||
for (b, (user, env, limit)) in bodies.iter().zip(expect) {
|
||||
assert_eq!(b["user"], user);
|
||||
assert_eq!(b["env"], env);
|
||||
assert_eq!(b["limit"].as_u64(), Some(limit));
|
||||
assert_eq!(b["cmd"], json!(["mixed"]));
|
||||
// Docker's one-shot names no working dir and no tty.
|
||||
assert_eq!(b["working_dir"], Value::Null);
|
||||
assert_eq!(b["tty"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_output_limit_refusal_passes_through_verbatim() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let e = target(&ep).exec_oneshot_limited("claude", cmd("big"), 5).await.unwrap_err();
|
||||
assert_eq!(e, "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned");
|
||||
assert!(e.starts_with(OUTPUT_LIMIT_MARKER));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_client_also_enforces_the_limit_on_what_it_buffers() {
|
||||
// An agent that ignored `limit` still cannot make the client buffer past it.
|
||||
let (ep, _) = fake_agent().await;
|
||||
let e = target(&ep).exec_oneshot_limited("claude", cmd("mixed"), 8).await.unwrap_err();
|
||||
assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{}", e);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_ids_come_from_the_same_probe_as_docker() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
assert_eq!(target(&ep).user_ids().await, (1000, 1001));
|
||||
let b = fake.seen.lock().unwrap().exec_bodies[0].clone();
|
||||
assert_eq!(b["cmd"], json!(["sh", "-c", "id -u; id -g"]));
|
||||
assert_eq!(b["user"], "claude");
|
||||
assert_eq!(b["limit"], 256);
|
||||
}
|
||||
|
||||
// ── detached, status, resize ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_detached_exec_is_polled_until_it_reports_its_exit() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
let spec = ExecSpec::new(cmd("sleep")).user("root").working_dir(None).no_stdin();
|
||||
let id = t.exec_detached(spec).await.unwrap();
|
||||
assert_eq!(id, "d1");
|
||||
assert_eq!(t.wait_exec_exit(&id).await, Some(7));
|
||||
let s = fake.seen.lock().unwrap();
|
||||
assert_eq!(s.status_polls, 3);
|
||||
assert_eq!(s.detached_bodies[0]["user"], "root");
|
||||
assert_eq!(s.detached_bodies[0]["working_dir"], Value::Null);
|
||||
drop(s);
|
||||
assert_eq!(t.wait_exec_exit("unknown").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_posts_cols_and_rows() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
target(&ep).resize_exec("a1", 120, 40).await.unwrap();
|
||||
let r = fake.seen.lock().unwrap().resizes.clone();
|
||||
assert_eq!(r, vec![("a1".to_string(), json!({"cols": 120, "rows": 40}))]);
|
||||
}
|
||||
|
||||
// ── attach ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_maps_channels_forwards_stdin_and_ends_on_exit() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
let spec = ExecSpec::new(cmd("cat")).env(vec!["A=1".into()]).tty(true);
|
||||
let AttachedExec {
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = t.attach(spec).await.unwrap();
|
||||
assert_eq!(exec_id, "a1");
|
||||
|
||||
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdOut(Bytes::from_static(b"out")));
|
||||
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdErr(Bytes::from_static(b"err")));
|
||||
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::Other(Bytes::from_static(b"odd")));
|
||||
|
||||
input.write_all(b"hello ").await.unwrap();
|
||||
input.write_all(b"agent").await.unwrap();
|
||||
input.shutdown().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output.next().await.unwrap().unwrap(),
|
||||
ExecChunk::StdOut(Bytes::from_static(b"hello agent"))
|
||||
);
|
||||
// The exit frame ends the stream…
|
||||
assert!(output.next().await.is_none());
|
||||
// …and is what answers wait_exec_exit (the fake 404s `a1`).
|
||||
assert_eq!(t.wait_exec_exit(&exec_id).await, Some(5));
|
||||
|
||||
let s = fake.seen.lock().unwrap();
|
||||
assert!(s.close_stdin);
|
||||
assert_eq!(s.stdin, b"hello agent");
|
||||
let hello = s.attach_hello.clone().unwrap();
|
||||
assert_eq!(hello["cmd"], json!(["cat"]));
|
||||
assert_eq!(hello["user"], "claude");
|
||||
assert_eq!(hello["working_dir"], "/workspace");
|
||||
assert_eq!(hello["env"], json!(["A=1"]));
|
||||
assert_eq!(hello["tty"], true);
|
||||
assert_eq!(hello["attach_stdin"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_attach_the_agent_refuses_is_an_error() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let Err(e) = target(&ep).attach(ExecSpec::new(cmd("nope"))).await else {
|
||||
panic!("a refused attach succeeded")
|
||||
};
|
||||
assert_eq!(e, "Failed to start exec: no such user");
|
||||
}
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_put_the_bytes_with_dir_name_mode_and_owner() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
assert_eq!(
|
||||
t.upload_bytes_with_ids("/tmp/", "x.txt", b"hi", 0o755, (1000, 1001))
|
||||
.await
|
||||
.unwrap(),
|
||||
"/tmp/x.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
t.upload_bytes("/home/claude/in", "p.tar", b"tar", 0o644).await.unwrap(),
|
||||
"/home/claude/in/p.tar"
|
||||
);
|
||||
// The terminal drop's nested name: Docker's tar entry would land it in the
|
||||
// subdirectory, so the agent is asked for that directory.
|
||||
assert_eq!(
|
||||
t.upload_bytes_with_ids("/tmp", "triple-c-drops/a.txt", b"a", 0o644, (1, 2))
|
||||
.await
|
||||
.unwrap(),
|
||||
"/tmp/triple-c-drops/a.txt"
|
||||
);
|
||||
assert!(t.upload_bytes("/tmp", "../etc/x", b"", 0o644).await.is_err());
|
||||
|
||||
let up = fake.seen.lock().unwrap().uploads.clone();
|
||||
let q = |i: usize, k: &str| up[i].0[k].clone();
|
||||
assert_eq!((q(0, "dir"), q(0, "name"), q(0, "mode")), ("/tmp".into(), "x.txt".into(), "755".into()));
|
||||
assert_eq!((q(0, "uid"), q(0, "gid")), ("1000".into(), "1001".into()));
|
||||
assert_eq!(up[0].1, b"hi");
|
||||
assert_eq!((q(1, "uid"), q(1, "gid"), q(1, "mode")), ("0".into(), "0".into(), "644".into()));
|
||||
assert_eq!((q(2, "dir"), q(2, "name")), ("/tmp/triple-c-drops".into(), "a.txt".into()));
|
||||
assert_eq!(up.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_host_file_upload_reads_through_the_shared_checked_reader() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("notes.txt");
|
||||
std::fs::write(&path, b"from the host").unwrap();
|
||||
let path = path.canonicalize().unwrap();
|
||||
let t = target(&ep);
|
||||
let got = t
|
||||
.upload_host_file_with_ids(path.to_str().unwrap(), "/workspace/", "notes.txt", (7, 8))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got, "/workspace/notes.txt");
|
||||
// Without ids: resolved through the uid probe first.
|
||||
t.upload_host_file(path.to_str().unwrap(), "/tmp", "n2.txt").await.unwrap();
|
||||
|
||||
let up = fake.seen.lock().unwrap().uploads.clone();
|
||||
assert_eq!(up[0].1, b"from the host");
|
||||
assert_eq!(up[0].0["mode"], "644");
|
||||
assert_eq!((up[0].0["uid"].as_str(), up[0].0["gid"].as_str()), ("7", "8"));
|
||||
assert_eq!((up[1].0["uid"].as_str(), up[1].0["gid"].as_str()), ("1000", "1001"));
|
||||
|
||||
let missing = dir.path().join("gone.txt");
|
||||
let e = t
|
||||
.upload_host_file_with_ids(missing.to_str().unwrap(), "/tmp", "g", (1, 1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.starts_with("Failed to read"), "{}", e);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_archive_is_streamed_and_a_missing_path_is_an_error() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
let mut s = t.download_archive("/workspace/a dir");
|
||||
let mut got = Vec::new();
|
||||
while let Some(chunk) = s.next().await {
|
||||
got.extend_from_slice(&chunk.unwrap());
|
||||
}
|
||||
assert_eq!(got, b"tar-part-1;tar-part-2;/workspace/a dir");
|
||||
|
||||
let mut s = t.download_archive("/missing");
|
||||
let e = s.next().await.unwrap().unwrap_err();
|
||||
assert_eq!(e, "Failed to download archive: no such path");
|
||||
assert!(s.next().await.is_none());
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_running_follows_whether_the_agent_answers() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
assert!(target(&ep).is_running().await.unwrap());
|
||||
assert!(!target(&ep).has_exited().await);
|
||||
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
drop(l);
|
||||
let gone = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN));
|
||||
assert!(!gone.is_running().await.unwrap());
|
||||
// Silence is not a positive exit.
|
||||
assert!(!gone.has_exited().await);
|
||||
}
|
||||
|
||||
// ── a hostile agent ──────────────────────────────────────────────────────────
|
||||
// The agent runs inside the container, which is not trusted with the host.
|
||||
|
||||
/// Serve `app` on an ephemeral loopback port; return the port.
|
||||
async fn serve(app: Router) -> u16 {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_redirect_from_the_agent_is_not_followed() {
|
||||
let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let elsewhere = {
|
||||
let hits = hits.clone();
|
||||
serve(Router::new().fallback(move || {
|
||||
let hits = hits.clone();
|
||||
async move {
|
||||
hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
"followed"
|
||||
}
|
||||
}))
|
||||
.await
|
||||
};
|
||||
let to = format!("http://127.0.0.1:{}/anything", elsewhere);
|
||||
let agent = serve(Router::new().fallback(move || {
|
||||
let to = to.clone();
|
||||
async move { (StatusCode::TEMPORARY_REDIRECT, [(axum::http::header::LOCATION, to)]) }
|
||||
}))
|
||||
.await;
|
||||
let t = ContainerTarget::Agent(AgentEndpoint::loopback(agent, TOKEN));
|
||||
|
||||
assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err());
|
||||
assert!(t.exec_oneshot(cmd("true")).await.is_err());
|
||||
assert!(t.is_running().await.is_err(), "a 3xx health answer is not 'running'");
|
||||
assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0, "the client followed a redirect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_oversized_error_body_is_neither_buffered_nor_passed_through_whole() {
|
||||
// 8 MiB of error text: past the small-body cap, so it is not even parsed.
|
||||
let huge = serve(Router::new().fallback(|| async {
|
||||
let body = json!({ "error": "x".repeat(8 * 1024 * 1024) });
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(body))
|
||||
}))
|
||||
.await;
|
||||
let e = ContainerTarget::Agent(AgentEndpoint::loopback(huge, TOKEN))
|
||||
.exec_detached(ExecSpec::new(cmd("true")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.len() < 1024, "error was {} bytes", e.len());
|
||||
assert!(e.contains("500"), "{e}");
|
||||
|
||||
// Under the body cap but long: parsed, then cut to the display cap.
|
||||
let long = serve(Router::new().fallback(|| async {
|
||||
let body = json!({ "error": "é".repeat(20 * 1024) });
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(body))
|
||||
}))
|
||||
.await;
|
||||
let e = ContainerTarget::Agent(AgentEndpoint::loopback(long, TOKEN))
|
||||
.exec_detached(ExecSpec::new(cmd("true")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.len() <= 4 * 1024 + 64, "error was {} bytes", e.len());
|
||||
assert!(e.ends_with('…'), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_that_accepts_and_then_says_nothing_cannot_hang_the_small_calls() {
|
||||
// Accepts every connection, reads nothing, answers nothing, holds it open.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
held.push(sock);
|
||||
}
|
||||
});
|
||||
let t = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN));
|
||||
let started = std::time::Instant::now();
|
||||
assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err());
|
||||
assert!(t.resize_exec("abc", 80, 24).await.is_err());
|
||||
assert_eq!(t.wait_exec_exit("abc").await, None);
|
||||
// Three calls, each bounded by SMALL_REQUEST_TIMEOUT (1 s under test).
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_that_cannot_be_a_header_value_is_refused_up_front() {
|
||||
let url = url::Url::parse("http://127.0.0.1:1/").unwrap();
|
||||
for bad in ["", "has space", "new\nline", "tab\there", "ünïcode"] {
|
||||
assert!(AgentEndpoint::new(url.clone(), bad).is_err(), "{bad:?} accepted");
|
||||
}
|
||||
assert!(AgentEndpoint::new(url, TOKEN).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_413_that_is_not_the_limit_message_is_not_passed_through() {
|
||||
let port = serve(Router::new().fallback(|| async {
|
||||
(StatusCode::PAYLOAD_TOO_LARGE, Json(json!({ "error": "click here: http://evil.example" })))
|
||||
}))
|
||||
.await;
|
||||
let e = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN))
|
||||
.exec_oneshot_limited("claude", cmd("true"), 1000)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{e}");
|
||||
assert!(!e.contains("evil"), "{e}");
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Bringing a project's container into existence, starting, stopping and
|
||||
//! removing it — the host-specific half of the project lifecycle commands.
|
||||
//!
|
||||
//! The commands in `commands/project_commands.rs` keep everything that is the
|
||||
//! same on every host: validating the LLM backend config, the Bedrock SSO
|
||||
//! check, project status transitions, the project lock, closing terminal
|
||||
//! sessions, the auth bridge, marketplace and credential syncs. They ask a
|
||||
//! [`ProjectHost`] for the rest. For Docker that is the
|
||||
//! find → maybe-snapshot-and-recreate → create → start sequence, which used to
|
||||
//! sit inline in `start_project_container_locked` and is moved here unchanged.
|
||||
//!
|
||||
//! Docker-only operations with no host-neutral meaning — base-image migration,
|
||||
//! snapshot sweeps, legacy cleanup at startup — keep calling `crate::docker`
|
||||
//! directly; see the boundary test in `host/mod.rs`.
|
||||
|
||||
use super::ContainerTarget;
|
||||
use crate::docker;
|
||||
use crate::models::{AppSettings, Project};
|
||||
use crate::models::container_config;
|
||||
|
||||
/// The host a project runs on. One variant today; an AWS variant will carry
|
||||
/// the connection, region and environment (see the spec's data model).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProjectHost {
|
||||
Docker,
|
||||
}
|
||||
|
||||
/// An agent target is a way *into* a container, not a handle on its
|
||||
/// lifecycle: whatever runs the container (ECS, the local harness) stops and
|
||||
/// removes it. Reaching here means a caller paired the Docker host with an
|
||||
/// agent target, which is a bug to surface, not a panic.
|
||||
const AGENT_LIFECYCLE: &str =
|
||||
"{op} a container is not supported for an agent target; the host that runs it owns its lifecycle";
|
||||
|
||||
/// What a host could not clean up when asked to discard a project's state.
|
||||
/// Docker: the snapshot image and the named volumes.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct DiscardLeftovers {
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProjectHost {
|
||||
pub fn for_project(_project: &Project) -> Self {
|
||||
ProjectHost::Docker
|
||||
}
|
||||
|
||||
/// Make sure the project's container exists, matches the project's
|
||||
/// current configuration, and is running. Returns where it runs.
|
||||
///
|
||||
/// `progress` receives the same human-readable steps the start view has
|
||||
/// always shown. Failure leaves the caller to reset the project status.
|
||||
pub async fn ensure_started(
|
||||
&self,
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
progress: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<ContainerTarget, String> {
|
||||
match self {
|
||||
ProjectHost::Docker => docker_ensure_started(project, settings, progress)
|
||||
.await
|
||||
.map(ContainerTarget::docker),
|
||||
}
|
||||
}
|
||||
|
||||
/// The container the host currently has for this project, found by asking
|
||||
/// the host rather than trusting `project.container_id` — that field can
|
||||
/// be stale (a crash between remove and the store update, a hand-edited
|
||||
/// `projects.json`).
|
||||
pub async fn find_current(&self, project: &Project) -> Result<Option<ContainerTarget>, String> {
|
||||
match self {
|
||||
ProjectHost::Docker => Ok(docker::find_existing_container(project)
|
||||
.await?
|
||||
.map(ContainerTarget::docker)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&self, target: &ContainerTarget) -> Result<(), String> {
|
||||
match target {
|
||||
ContainerTarget::Docker { container_id } => docker::stop_container(container_id).await,
|
||||
ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Stopping")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop (errors ignored — a stopped container is the goal) and remove.
|
||||
pub async fn remove(&self, target: &ContainerTarget) -> Result<(), String> {
|
||||
match target {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await
|
||||
}
|
||||
ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Removing")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete what outlives the container — for Docker the snapshot image and
|
||||
/// both named volumes (`~/.claude`, the OAuth login and transcripts go with
|
||||
/// them). Best-effort; reports what it could not remove.
|
||||
pub async fn discard_project_state(&self, project: &Project) -> DiscardLeftovers {
|
||||
match self {
|
||||
ProjectHost::Docker => {
|
||||
let mut left = DiscardLeftovers::default();
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project.id, e);
|
||||
left.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
left.volumes = docker::remove_project_volumes(project).await;
|
||||
left
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove leftovers of features this host no longer has (Docker: the
|
||||
/// deleted MCP feature's containers and network).
|
||||
pub async fn remove_legacy_leftovers(&self, project: &Project) {
|
||||
match self {
|
||||
ProjectHost::Docker => {
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Docker start sequence, moved here verbatim from
|
||||
/// `start_project_container_locked`. Returns the running container's id.
|
||||
async fn docker_ensure_started(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
progress: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<String, String> {
|
||||
let image_name =
|
||||
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Ensure image exists
|
||||
progress("Checking image...");
|
||||
if !docker::image_exists(&image_name).await? {
|
||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||
}
|
||||
|
||||
// Determine docker socket path
|
||||
let docker_socket = settings.docker_socket_path
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| docker::default_docker_socket());
|
||||
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||
// Snapshot the filesystem before destroying
|
||||
progress("Saving container state...");
|
||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, project).await {
|
||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||
}
|
||||
progress("Recreating container...");
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Legacy MCP cleanup: the old container may have been attached to
|
||||
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||
// that network now, before the replacement is created without it.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = docker::create_container_for_project(
|
||||
project,
|
||||
settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
progress("Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
// The commit above moved `:latest` and orphaned the image it
|
||||
// used to point at; the container holding that image open was
|
||||
// removed a few lines up, so now is when Docker will actually
|
||||
// let it go. Detached because this is housekeeping and the
|
||||
// project is already running — and it sweeps every orphan, not
|
||||
// just this one, so recreations that happened before the sweep
|
||||
// existed are cleaned up too.
|
||||
tauri::async_runtime::spawn(async {
|
||||
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
||||
});
|
||||
|
||||
new_id
|
||||
} else {
|
||||
progress("Starting container...");
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
}
|
||||
} else {
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
progress("Creating container...");
|
||||
let new_id = docker::create_container_for_project(
|
||||
project,
|
||||
settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
progress("Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End to end against a real daemon and the registry sandbox image: a
|
||||
/// throwaway project (fresh UUID, so its container, volumes and snapshot
|
||||
/// name are its own) is started, reached, stopped, started again on the
|
||||
/// *same* container, then removed and its state discarded.
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon and the triple-c-sandbox image; creates and removes a throwaway project container"]
|
||||
async fn a_project_container_round_trips_through_the_host() {
|
||||
let project = Project::new("lifecycle-live-test".to_string(), Vec::new());
|
||||
let settings = AppSettings::default();
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let steps = std::sync::Mutex::new(Vec::<String>::new());
|
||||
let progress = |m: &str| steps.lock().unwrap().push(m.to_string());
|
||||
// Failures inside the round trip return instead of panicking, so the
|
||||
// cleanup below always runs.
|
||||
fn check(ok: bool, what: &str) -> Result<(), String> {
|
||||
if ok { Ok(()) } else { Err(format!("check failed: {what}")) }
|
||||
}
|
||||
|
||||
let outcome = async {
|
||||
check(host.find_current(&project).await?.is_none(), "no container before the first start")?;
|
||||
|
||||
let first = host.ensure_started(&project, &settings, &progress).await?;
|
||||
check(first.is_running().await?, "running after start")?;
|
||||
let (out, code) = first
|
||||
.exec_oneshot_env_status(vec!["id".into(), "-un".into()], vec![])
|
||||
.await?;
|
||||
check(out.trim() == "claude" && code == 0, "exec runs as claude")?;
|
||||
check(host.find_current(&project).await? == Some(first.clone()), "find_current names it")?;
|
||||
|
||||
host.stop(&first).await?;
|
||||
check(first.has_exited().await, "exited after stop")?;
|
||||
|
||||
// Same config, so the existing container is started, not recreated.
|
||||
let second = host.ensure_started(&project, &settings, &progress).await?;
|
||||
check(second == first, "restart reuses the container")?;
|
||||
check(second.is_running().await?, "running after restart")?;
|
||||
|
||||
host.remove(&second).await?;
|
||||
check(host.find_current(&project).await?.is_none(), "gone after remove")?;
|
||||
Ok::<_, String>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Always clean up, even after a failed assertion above.
|
||||
if let Ok(Some(t)) = host.find_current(&project).await {
|
||||
let _ = host.remove(&t).await;
|
||||
}
|
||||
let left = host.discard_project_state(&project).await;
|
||||
outcome.expect("round trip");
|
||||
assert_eq!(left, DiscardLeftovers::default(), "nothing left behind");
|
||||
let steps = steps.into_inner().unwrap();
|
||||
assert_eq!(
|
||||
steps,
|
||||
[
|
||||
"Checking image...",
|
||||
"Creating container...",
|
||||
"Starting container...",
|
||||
"Checking image...",
|
||||
"Starting container...",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
//! Where a project's container runs, and the one interface the rest of the app
|
||||
//! uses to reach inside it.
|
||||
//!
|
||||
//! Spec: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` ("Host
|
||||
//! abstraction"). This is phase 1 of that plan: every operation that happens
|
||||
//! *inside* a project container — one-shot and attached execs, file transfer,
|
||||
//! "is it running" — goes through [`ContainerTarget`] instead of naming
|
||||
//! `docker::exec` or bollard directly. Today the only variant is
|
||||
//! [`ContainerTarget::Docker`], which delegates to the existing code in
|
||||
//! `docker/exec.rs` and `docker/container.rs` with no change in behaviour.
|
||||
//! [`ContainerTarget::Agent`] reaches a container through the in-container
|
||||
//! `triple-c-agent` instead (`agent.rs`), which is how the AWS host will get in.
|
||||
//!
|
||||
//! ## Why an enum and not a trait object
|
||||
//!
|
||||
//! The set of hosts is closed and known at compile time, every method is async,
|
||||
//! and a `dyn` async trait would need boxing on every call (or the
|
||||
//! `async-trait` crate) for no benefit. Adding a host means adding a variant and
|
||||
//! letting the compiler point at every `match` that needs an arm.
|
||||
//!
|
||||
//! ## What does *not* go through here
|
||||
//!
|
||||
//! Things that only make sense for the local Docker host: image build/pull,
|
||||
//! base-image migration, snapshot commits, volumes, the LiteLLM gateway and STT
|
||||
//! sidecars, and (until phase 1b) container create/start/stop and the `die`
|
||||
//! event stream. Those keep calling `crate::docker` directly. The
|
||||
//! `no_container_interior_bollard_outside_the_host_layer` test pins which files
|
||||
//! may still do so.
|
||||
|
||||
pub mod agent;
|
||||
pub mod lifecycle;
|
||||
pub mod sessions;
|
||||
// Nothing in the app opens an SSM tunnel until the AWS host lands (phase 5 of
|
||||
// the AWS spec); its tests exercise it meanwhile. Remove this allow then.
|
||||
#[allow(dead_code)]
|
||||
pub mod ssm;
|
||||
pub mod watch;
|
||||
|
||||
pub use crate::docker::exec::{
|
||||
MAX_DROP_BYTES, OUTPUT_LIMIT_MARKER, PROC_NET_OUTPUT_LIMIT,
|
||||
};
|
||||
pub use agent::AgentEndpoint;
|
||||
pub use sessions::ExecSessionManager;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::Stream;
|
||||
use std::pin::Pin;
|
||||
use tokio::io::AsyncWrite;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::Project;
|
||||
|
||||
/// One chunk of output from an exec, already demultiplexed.
|
||||
///
|
||||
/// Host-neutral stand-in for bollard's `LogOutput`. With `tty = true` there is
|
||||
/// no stdout/stderr split and everything arrives as [`ExecChunk::StdOut`] (Docker
|
||||
/// reports a TTY stream as `Console`, which maps here too — consumers that cared
|
||||
/// treated the two the same).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExecChunk {
|
||||
StdOut(Bytes),
|
||||
StdErr(Bytes),
|
||||
/// stdin echo or anything else a host might surface. Consumers ignore it.
|
||||
Other(Bytes),
|
||||
}
|
||||
|
||||
impl ExecChunk {
|
||||
pub fn into_bytes(self) -> Bytes {
|
||||
match self {
|
||||
ExecChunk::StdOut(b) | ExecChunk::StdErr(b) | ExecChunk::Other(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ExecOutputStream = Pin<Box<dyn Stream<Item = Result<ExecChunk, String>> + Send>>;
|
||||
|
||||
/// An exec that has been created and started with its streams attached.
|
||||
///
|
||||
/// `input` is a sink that is never read when the exec was opened with
|
||||
/// `attach_stdin: false`.
|
||||
pub struct AttachedExec {
|
||||
pub exec_id: String,
|
||||
pub output: ExecOutputStream,
|
||||
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||
}
|
||||
|
||||
/// Everything about an attached exec except where it runs.
|
||||
///
|
||||
/// Defaults match what this app has always used: run as `claude` in
|
||||
/// `/workspace`, no TTY, stdin attached, no extra env.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecSpec {
|
||||
pub cmd: Vec<String>,
|
||||
pub user: String,
|
||||
pub working_dir: Option<String>,
|
||||
pub env: Vec<String>,
|
||||
pub tty: bool,
|
||||
pub attach_stdin: bool,
|
||||
}
|
||||
|
||||
impl ExecSpec {
|
||||
pub fn new(cmd: Vec<String>) -> Self {
|
||||
Self {
|
||||
cmd,
|
||||
user: "claude".to_string(),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
env: Vec::new(),
|
||||
tty: false,
|
||||
attach_stdin: true,
|
||||
}
|
||||
}
|
||||
pub fn user(mut self, user: &str) -> Self {
|
||||
self.user = user.to_string();
|
||||
self
|
||||
}
|
||||
pub fn working_dir(mut self, dir: Option<&str>) -> Self {
|
||||
self.working_dir = dir.map(str::to_string);
|
||||
self
|
||||
}
|
||||
pub fn env(mut self, env: Vec<String>) -> Self {
|
||||
self.env = env;
|
||||
self
|
||||
}
|
||||
pub fn tty(mut self, tty: bool) -> Self {
|
||||
self.tty = tty;
|
||||
self
|
||||
}
|
||||
pub fn no_stdin(mut self) -> Self {
|
||||
self.attach_stdin = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte stream of a tar archive read out of a container.
|
||||
pub type ArchiveStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>;
|
||||
|
||||
/// A specific project container on a specific host.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ContainerTarget {
|
||||
/// A container on the local Docker daemon, by id or name.
|
||||
Docker { container_id: String },
|
||||
/// A container reached through the in-container `triple-c-agent`
|
||||
/// (`host/agent.rs`; protocol in
|
||||
/// `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`). Used
|
||||
/// by the AWS host (later) and the local agent harness. The host that
|
||||
/// runs the container, not this target, owns its lifecycle.
|
||||
Agent(AgentEndpoint),
|
||||
}
|
||||
|
||||
impl ContainerTarget {
|
||||
pub fn docker(container_id: impl Into<String>) -> Self {
|
||||
ContainerTarget::Docker {
|
||||
container_id: container_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The target for a project's container, if it has one yet.
|
||||
pub fn for_project(project: &Project) -> Option<Self> {
|
||||
project.container_id.as_deref().map(Self::docker)
|
||||
}
|
||||
|
||||
/// The host-specific identifier (a Docker container id today). For logs
|
||||
/// and for keying per-container state; never parse it.
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => container_id,
|
||||
ContainerTarget::Agent(ep) => ep.base_url.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether the container is running. An inspect failure (gone, daemon
|
||||
/// down) reads as `false`, as `docker::is_container_running` always has.
|
||||
pub async fn is_running(&self) -> Result<bool, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::container::is_container_running(container_id).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::is_running(ep).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────
|
||||
|
||||
/// Run as `claude`, return combined output. Errors on a non-UTF-8-safe
|
||||
/// transport failure only; a non-zero exit is *not* an error here.
|
||||
pub async fn exec_oneshot(&self, cmd: Vec<String>) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot(container_id, cmd).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, Vec::new())
|
||||
.await
|
||||
.map(|(out, _)| out),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_env(
|
||||
&self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_env(container_id, cmd, env).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, env)
|
||||
.await
|
||||
.map(|(out, _)| out),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_env_status(
|
||||
&self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_env_status(container_id, cmd, env).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, env).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_as(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_as(container_id, user, cmd, env).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, user, cmd, env).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_limited(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_limited(container_id, user, cmd, limit).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::exec_oneshot_limited(ep, user, cmd, Vec::new(), limit).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// stdout and stderr kept apart: `(stdout, stderr, exit_code)`.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_streams_as(container_id, user, cmd, env).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_as(ep, user, cmd, env).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a command and return without waiting or reading its output.
|
||||
/// Returns the exec id.
|
||||
pub async fn exec_detached(&self, spec: ExecSpec) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_detached(container_id, &spec).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_detached(ep, &spec).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── attached execs ───────────────────────────────────────────────────────
|
||||
|
||||
/// Run as `claude` in `/workspace` with stdin attached.
|
||||
pub async fn attach_exec(&self, cmd: Vec<String>, tty: bool) -> Result<AttachedExec, String> {
|
||||
self.attach(ExecSpec::new(cmd).tty(tty)).await
|
||||
}
|
||||
|
||||
pub async fn attach(&self, spec: ExecSpec) -> Result<AttachedExec, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::create_attached_exec_spec(container_id, &spec).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::attach(ep, &spec).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit code of an exec this target started, once it has finished.
|
||||
/// `None` if the host could not say.
|
||||
pub async fn wait_exec_exit(&self, exec_id: &str) -> Option<i64> {
|
||||
match self {
|
||||
ContainerTarget::Docker { .. } => docker::exec::wait_for_exec_exit(exec_id).await,
|
||||
ContainerTarget::Agent(ep) => agent::wait_exec_exit(ep, exec_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resize_exec(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { .. } => docker::exec::resize_exec(exec_id, cols, rows).await,
|
||||
ContainerTarget::Agent(ep) => agent::resize_exec(ep, exec_id, cols, rows).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The container user's `(uid, gid)`, falling back to `(1000, 1000)`.
|
||||
pub async fn user_ids(&self) -> (u64, u64) {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::container_user_ids(container_id).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::user_ids(ep).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `data` to `dest_dir/file_name` as root-owned with `mode`.
|
||||
pub async fn upload_bytes(
|
||||
&self,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_bytes_to_container(
|
||||
container_id,
|
||||
dest_dir,
|
||||
file_name,
|
||||
data,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, (0, 0)).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::upload_bytes`] with the owner set to `ids`.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
&self,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_bytes_with_ids(
|
||||
container_id,
|
||||
dest_dir,
|
||||
file_name,
|
||||
data,
|
||||
mode,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, ids).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a host file in, owned by the container user.
|
||||
pub async fn upload_host_file(
|
||||
&self,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_host_file_to_container(
|
||||
container_id,
|
||||
host_path,
|
||||
dest_dir,
|
||||
dest_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
let ids = agent::user_ids(ep).await;
|
||||
agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::upload_host_file`] with the owner ids already known.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
&self,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_host_file_with_ids(
|
||||
container_id,
|
||||
host_path,
|
||||
dest_dir,
|
||||
dest_name,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `path` out of the container as a tar stream (Docker's archive
|
||||
/// API). Dropping the stream cancels the transfer.
|
||||
pub fn download_archive(&self, path: &str) -> ArchiveStream {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::download_archive(container_id, path)
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::download_archive(ep, path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exec_spec_defaults_match_what_the_app_always_used() {
|
||||
let s = ExecSpec::new(vec!["true".into()]);
|
||||
assert_eq!(s.user, "claude");
|
||||
assert_eq!(s.working_dir.as_deref(), Some("/workspace"));
|
||||
assert!(s.env.is_empty());
|
||||
assert!(!s.tty);
|
||||
assert!(s.attach_stdin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_project_follows_the_container_id() {
|
||||
let mut p: Project = serde_json::from_value(serde_json::json!({
|
||||
"id": "p1", "name": "p", "paths": [], "container_id": null,
|
||||
"status": "stopped", "backend": "anthropic",
|
||||
"allow_docker_access": false, "full_permissions": false,
|
||||
"created_at": "now", "updated_at": "now"
|
||||
}))
|
||||
.expect("minimal project deserializes");
|
||||
assert_eq!(ContainerTarget::for_project(&p), None);
|
||||
p.container_id = Some("abc".into());
|
||||
assert_eq!(
|
||||
ContainerTarget::for_project(&p),
|
||||
Some(ContainerTarget::docker("abc"))
|
||||
);
|
||||
assert_eq!(ContainerTarget::docker("abc").id(), "abc");
|
||||
}
|
||||
|
||||
/// Every non-`docker/`, non-`host/` source file, with its path relative to
|
||||
/// `src/`, minus the files in `allowed`.
|
||||
fn sources_outside_the_host_layer(allowed: &[&str]) -> Vec<(String, String)> {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![src.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let rel = path
|
||||
.strip_prefix(&src)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if path.is_dir() {
|
||||
if rel != "docker" && rel != "host" {
|
||||
stack.push(path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if rel.ends_with(".rs") && !allowed.contains(&rel.as_str()) {
|
||||
out.push((rel, std::fs::read_to_string(&path).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Docker-only by nature: base-image migration replays apt, restores a
|
||||
/// payload, commits and swaps containers. It has no AWS meaning.
|
||||
const DOCKER_ONLY: &[&str] = &["commands/migration_commands.rs"];
|
||||
|
||||
/// The host boundary, pinned: outside `docker/` and `host/`, nothing names
|
||||
/// bollard or `docker::exec`. Container-interior code goes through
|
||||
/// [`ContainerTarget`]. Shrink [`DOCKER_ONLY`]; do not grow it.
|
||||
#[test]
|
||||
fn no_container_interior_bollard_outside_the_host_layer() {
|
||||
let offenders: Vec<String> = sources_outside_the_host_layer(DOCKER_ONLY)
|
||||
.into_iter()
|
||||
.filter(|(_, text)| {
|
||||
text.contains("bollard::")
|
||||
|| text.contains("docker::exec")
|
||||
|| text.contains("get_docker")
|
||||
})
|
||||
.map(|(rel, _)| rel)
|
||||
.collect();
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"these files reach into the container without going through host::ContainerTarget: {:?}",
|
||||
offenders
|
||||
);
|
||||
}
|
||||
|
||||
/// The lifecycle half of the boundary: outside `docker/` and `host/`, no
|
||||
/// non-test code creates, starts, stops, finds or recreates a container
|
||||
/// itself (that is [`lifecycle::ProjectHost`]'s job), and none builds a
|
||||
/// [`ContainerTarget::docker`] from a bare id, which would hard-code the
|
||||
/// host. Comments and `#[cfg(test)]` code are not checked.
|
||||
///
|
||||
/// `docker::remove_container` is deliberately absent: startup's
|
||||
/// pending-cleanup retry removes Docker leftovers it recorded *by name*,
|
||||
/// which is Docker housekeeping, not lifecycle.
|
||||
#[test]
|
||||
fn no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer() {
|
||||
const BANNED: &[&str] = &[
|
||||
"docker::create_container(",
|
||||
"docker::start_container(",
|
||||
"docker::stop_container(",
|
||||
"docker::find_existing_container(",
|
||||
"docker::container_needs_recreation(",
|
||||
"docker::commit_container_snapshot(",
|
||||
"docker::remove_snapshot_image(",
|
||||
"docker::remove_project_volumes(",
|
||||
"docker::is_container_running(",
|
||||
"container::is_container_running(",
|
||||
"ContainerTarget::docker(",
|
||||
];
|
||||
let mut offenders = Vec::new();
|
||||
for (rel, text) in sources_outside_the_host_layer(DOCKER_ONLY) {
|
||||
let code = text.split("#[cfg(test)]").next().unwrap_or("");
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
for pat in BANNED {
|
||||
if line.contains(pat) {
|
||||
offenders.push(format!("{}:{}: {}", rel, n + 1, pat));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"container lifecycle / bare Docker targets outside host::lifecycle:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Interactive exec sessions — terminals and the audio bridge — keyed by the
|
||||
//! frontend's session id. Host-neutral: every session holds the
|
||||
//! [`ContainerTarget`] it was opened on and does all its I/O through it.
|
||||
//! (Moved here from `docker/exec.rs` in phase 1 of the AWS host work.)
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use super::{AttachedExec, ContainerTarget};
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub target: ContainerTarget,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
self.target.resize_exec(&self.exec_id, cols, rows).await
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(target, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let AttachedExec {
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = target.attach_exec(cmd, tty).await?;
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// Output reader task
|
||||
let session_id_clone = session_id.to_string();
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = output.next() => {
|
||||
match msg {
|
||||
Some(Ok(output)) => {
|
||||
on_output(output.into_bytes().to_vec());
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Exec output stream ended for {}", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
on_exit();
|
||||
let _ = shutdown_tx_clone;
|
||||
});
|
||||
|
||||
// Input writer task
|
||||
tokio::spawn(async move {
|
||||
while let Some(data) = input_rx.recv().await {
|
||||
if let Err(e) = input.write_all(&data).await {
|
||||
log::error!("Failed to write to exec stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
target: target.clone(),
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
// Clone the exec_id under the lock, then drop the lock before the
|
||||
// async host call to avoid holding the mutex across await.
|
||||
let (exec_id, target) = {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
(session.exec_id.clone(), session.target.clone())
|
||||
};
|
||||
target.resize_exec(&exec_id, cols, rows).await
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let ids_to_close: Vec<String> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.target.id() == container_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids_to_close {
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// The container a session was opened on — reuse it for side-channel work
|
||||
/// on the same session (image paste, file drop, audio bridge) rather than
|
||||
/// rebuilding a target from an id, which would assume the host.
|
||||
pub async fn get_target(&self, session_id: &str) -> Result<ContainerTarget, String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
Ok(session.target.clone())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_container(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let ids = target.user_ids().await;
|
||||
target
|
||||
.upload_bytes_with_ids("/tmp", file_name, data, 0o644, ids)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
//! The transport to an agent in an ECS task: one SSM port-forwarding session
|
||||
//! (`AWS-StartPortForwardingSession`) from a loopback port on this machine to
|
||||
//! the agent's port inside the task, kept open for as long as the project is
|
||||
//! connected and reopened when it drops. Everything else — execs, files,
|
||||
//! tunnels — then goes through [`AgentEndpoint`] exactly as it does locally.
|
||||
//! Design: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`
|
||||
//! ("Connectivity").
|
||||
//!
|
||||
//! The SSM data channel is a custom websocket protocol the Rust SDK does not
|
||||
//! implement, so v1 runs AWS's `session-manager-plugin` as a child process,
|
||||
//! the way the AWS CLI does: the app calls `ssm:StartSession` itself and hands
|
||||
//! the plugin the stream URL and token.
|
||||
//!
|
||||
//! Three things here are deliberate:
|
||||
//!
|
||||
//! - **The session token never goes on a command line.** The plugin reads the
|
||||
//! `StartSession` response from the environment variable named by its first
|
||||
//! argument (`AWS_SSM_START_SESSION_RESPONSE`, what the AWS CLI does for
|
||||
//! plugins that support it), so it is not visible in `ps`.
|
||||
//! - **The plugin gets the caller's current credentials in its environment,
|
||||
//! and nothing else of AWS's.** It needs them only to resume its data
|
||||
//! channel after a network blip (`ssm:ResumeSession`), which keeps every TCP
|
||||
//! connection through the tunnel — open terminals included — alive. Any
|
||||
//! `AWS_*` variable this process happens to have is removed first, so the
|
||||
//! plugin can never act as a different identity than the one that started
|
||||
//! the session. When those credentials have expired a resume fails, the
|
||||
//! plugin exits, and the supervisor here opens a fresh session with fresh
|
||||
//! credentials on the **same local port**, so an [`AgentEndpoint`] built
|
||||
//! once stays valid across reconnects (open connections do not survive that
|
||||
//! path; new ones succeed).
|
||||
//! - **Whoever holds a [`PortForward`] must `close()` it when the app exits.**
|
||||
//! Tauri exits through `std::process::exit`, which drops no runtime tasks,
|
||||
//! so `kill_on_drop` never fires and the plugin would keep the session and
|
||||
//! the port until SSM's idle timeout.
|
||||
//! - **The plugin listens on `127.0.0.1` only** (checked: plugin 1.2.896), and
|
||||
//! the agent behind it still wants its bearer token. The SSM session's IAM
|
||||
//! check is the outer gate.
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::{CancellationToken, DropGuard};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::AgentEndpoint;
|
||||
|
||||
/// Where `triple-c-agent` listens inside the task (its default).
|
||||
pub const AGENT_PORT: u16 = 47900;
|
||||
|
||||
const PORT_FORWARD_DOCUMENT: &str = "AWS-StartPortForwardingSession";
|
||||
/// The environment variable the plugin reads the `StartSession` response from.
|
||||
const RESPONSE_ENV: &str = "AWS_SSM_START_SESSION_RESPONSE";
|
||||
/// What the plugin prints once its local listener is up.
|
||||
const READY_LINE: &str = "Waiting for connections";
|
||||
/// Plugin start to listener up. Measured at ~1-2 s; this bounds a wedged one.
|
||||
const READY_TIMEOUT: Duration = if cfg!(test) {
|
||||
Duration::from_secs(2)
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
};
|
||||
/// The last lines of plugin output kept for error messages.
|
||||
const OUTPUT_TAIL_LINES: usize = 20;
|
||||
/// Reconnect delays; the last repeats. A reconnect that keeps failing for
|
||||
/// about ten minutes gives up ([`TunnelState::Failed`]) — by then the task is
|
||||
/// gone or the credentials need the user, and the host layer decides what the
|
||||
/// project's state is.
|
||||
const RECONNECT_BACKOFF: &[Duration] = &[
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(2),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(10),
|
||||
Duration::from_secs(30),
|
||||
];
|
||||
const MAX_RECONNECT_ATTEMPTS: u32 = 25;
|
||||
/// Attempts to find a free local port for the first connection.
|
||||
const INITIAL_PORT_ATTEMPTS: u32 = 3;
|
||||
const TERMINATE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// How often an Up tunnel sends traffic of its own. Session Manager ends a
|
||||
/// session after 20 minutes with no data (its default `idleSessionTimeout`;
|
||||
/// measured: a silent tunnel was cut at 19.7 min), and with it every TCP
|
||||
/// connection inside — an idle terminal tab included.
|
||||
const KEEPALIVE_INTERVAL: Duration = if cfg!(test) {
|
||||
Duration::from_millis(200)
|
||||
} else {
|
||||
Duration::from_secs(5 * 60)
|
||||
};
|
||||
const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
// ── The task ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An ECS task container that an SSM session can target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EcsTask {
|
||||
/// The cluster's *name* (the SSM target wants the name, not the ARN).
|
||||
pub cluster: String,
|
||||
pub task_id: String,
|
||||
/// The container's runtime id, from `DescribeTasks`.
|
||||
pub runtime_id: String,
|
||||
}
|
||||
|
||||
impl EcsTask {
|
||||
/// `ecs:<cluster>_<taskId>_<runtimeId>`, the SSM target for one container
|
||||
/// of one task.
|
||||
pub fn ssm_target(&self) -> String {
|
||||
format!("ecs:{}_{}_{}", self.cluster, self.task_id, self.runtime_id)
|
||||
}
|
||||
|
||||
/// Look up `container` in `task` (an id or ARN) on `cluster` (a name or
|
||||
/// ARN), and check it can take a session now: the task is running and
|
||||
/// its ECS Exec agent is up. Fargate starts that agent a few seconds after
|
||||
/// the container, so "not ready yet" is a normal answer just after start.
|
||||
pub async fn resolve(
|
||||
ecs: &aws_sdk_ecs::Client,
|
||||
cluster: &str,
|
||||
task: &str,
|
||||
container: &str,
|
||||
) -> Result<Self, String> {
|
||||
let cluster_name = last_arn_segment(cluster, "cluster/");
|
||||
let out = ecs
|
||||
.describe_tasks()
|
||||
.cluster(cluster)
|
||||
.tasks(task)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Could not look up the ECS task: {}", sdk_error(&e)))?;
|
||||
let t = out
|
||||
.tasks()
|
||||
.first()
|
||||
.ok_or_else(|| format!("ECS task {task} was not found in cluster {cluster_name}"))?;
|
||||
let task_arn = t.task_arn().unwrap_or(task);
|
||||
let task_id = last_arn_segment(task_arn, "/").to_string();
|
||||
let status = t.last_status().unwrap_or("UNKNOWN");
|
||||
if status != "RUNNING" {
|
||||
return Err(format!("ECS task {task_id} is {status}, not RUNNING"));
|
||||
}
|
||||
let c = t
|
||||
.containers()
|
||||
.iter()
|
||||
.find(|c| c.name() == Some(container))
|
||||
.ok_or_else(|| format!("ECS task {task_id} has no container named {container}"))?;
|
||||
let runtime_id = c
|
||||
.runtime_id()
|
||||
.ok_or_else(|| format!("Container {container} in task {task_id} has no runtime id yet"))?
|
||||
.to_string();
|
||||
let exec_agent = c
|
||||
.managed_agents()
|
||||
.iter()
|
||||
.find(|a| a.name().map(|n| n.as_str()) == Some("ExecuteCommandAgent"));
|
||||
match exec_agent.and_then(|a| a.last_status()) {
|
||||
Some("RUNNING") => {}
|
||||
Some(s) => {
|
||||
return Err(format!(
|
||||
"Container {container} in task {task_id} is not ready for connections yet (ECS Exec agent {s})"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(format!(
|
||||
"ECS task {task_id} was started without ECS Exec, so it cannot be connected to"
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
cluster: cluster_name.to_string(),
|
||||
task_id,
|
||||
runtime_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of an ARN after the last `marker` (`.../cluster/name` → `name`),
|
||||
/// or the whole string when it is not an ARN.
|
||||
fn last_arn_segment<'a>(s: &'a str, marker: &str) -> &'a str {
|
||||
if !s.starts_with("arn:") {
|
||||
return s;
|
||||
}
|
||||
match s.rfind(marker) {
|
||||
Some(i) => &s[i + marker.len()..],
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// An SDK error with its cause chain (the bare `Display` is often just
|
||||
/// "service error").
|
||||
fn sdk_error(e: &(dyn std::error::Error + 'static)) -> String {
|
||||
format!("{}", aws_sdk_ssm::error::DisplayErrorContext(e))
|
||||
}
|
||||
|
||||
// ── The plugin ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Find `session-manager-plugin`: `TRIPLEC_SSM_PLUGIN` if set, then `PATH`,
|
||||
/// then where AWS's installers put it (the GUI app on macOS does not get the
|
||||
/// shell's `PATH`, so the install locations are not optional).
|
||||
pub fn find_plugin() -> Result<PathBuf, String> {
|
||||
if let Some(p) = std::env::var_os("TRIPLEC_SSM_PLUGIN").filter(|p| !p.is_empty()) {
|
||||
let p = PathBuf::from(p);
|
||||
return if p.is_file() {
|
||||
Ok(p)
|
||||
} else {
|
||||
Err(format!("TRIPLEC_SSM_PLUGIN names {}, which is not a file", p.display()))
|
||||
};
|
||||
}
|
||||
let name = if cfg!(windows) { "session-manager-plugin.exe" } else { "session-manager-plugin" };
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.map(|paths| std::env::split_paths(&paths).map(|d| d.join(name)).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let installed: Vec<PathBuf> = if cfg!(windows) {
|
||||
["ProgramFiles", "ProgramFiles(x86)"]
|
||||
.iter()
|
||||
.filter_map(|v| std::env::var_os(v))
|
||||
.map(|d| PathBuf::from(d).join(r"Amazon\SessionManagerPlugin\bin").join(name))
|
||||
.collect()
|
||||
} else {
|
||||
[
|
||||
"/usr/local/sessionmanagerplugin/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/bin",
|
||||
]
|
||||
.iter()
|
||||
.map(|d| Path::new(d).join(name))
|
||||
.collect()
|
||||
};
|
||||
on_path
|
||||
.into_iter()
|
||||
.chain(installed)
|
||||
.find(|p| p.is_file())
|
||||
.ok_or_else(|| {
|
||||
"The AWS Session Manager plugin (session-manager-plugin) is not installed. It is \
|
||||
what connects to a workspace on AWS; install it from \
|
||||
https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// A running plugin process with a listener on the local port.
|
||||
struct Running {
|
||||
child: Child,
|
||||
session_id: String,
|
||||
output: OutputTail,
|
||||
}
|
||||
|
||||
/// The last lines a plugin printed (stdout and stderr), for error text.
|
||||
#[derive(Clone, Default)]
|
||||
struct OutputTail(Arc<Mutex<Vec<String>>>);
|
||||
|
||||
impl OutputTail {
|
||||
fn push(&self, line: String) {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
v.push(line);
|
||||
if v.len() > OUTPUT_TAIL_LINES {
|
||||
v.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The most informative recent line: the last non-blank one.
|
||||
fn last(&self) -> Option<String> {
|
||||
self.0.lock().unwrap().iter().rev().find(|l| !l.trim().is_empty()).cloned()
|
||||
}
|
||||
|
||||
fn joined(&self) -> String {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
}
|
||||
|
||||
/// How to start the plugin for one session. Everything but `env_secret` is
|
||||
/// safe to log; that is the `StartSession` response.
|
||||
struct PluginLaunch {
|
||||
program: PathBuf,
|
||||
args: Vec<String>,
|
||||
env: Vec<(String, Zeroizing<String>)>,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
/// Start the plugin and wait until it says its listener is up. Its output is
|
||||
/// then drained in the background for good (a full pipe would stall it).
|
||||
async fn spawn_plugin(launch: PluginLaunch) -> Result<Running, String> {
|
||||
let mut cmd = Command::new(&launch.program);
|
||||
cmd.args(&launch.args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
// Only the credentials this session was started with: nothing AWS-shaped
|
||||
// from our own environment rides along.
|
||||
for (k, _) in std::env::vars_os() {
|
||||
if k.to_string_lossy().starts_with("AWS_") {
|
||||
cmd.env_remove(&k);
|
||||
}
|
||||
}
|
||||
for (k, v) in &launch.env {
|
||||
cmd.env(k, v.as_str());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// CREATE_NO_WINDOW: no console flashing up per (re)connect.
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not start {}: {e}", launch.program.display()))?;
|
||||
|
||||
let output = OutputTail::default();
|
||||
let (ready_tx, mut ready_rx) = watch::channel(false);
|
||||
let stdout = child.stdout.take().expect("stdout is piped");
|
||||
let stderr = child.stderr.take().expect("stderr is piped");
|
||||
drain(stdout, output.clone(), Some(ready_tx));
|
||||
drain(stderr, output.clone(), None);
|
||||
|
||||
let ready = async {
|
||||
loop {
|
||||
if *ready_rx.borrow_and_update() {
|
||||
return true;
|
||||
}
|
||||
if ready_rx.changed().await.is_err() {
|
||||
// stdout closed without the ready line: the plugin is exiting.
|
||||
return *ready_rx.borrow();
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(READY_TIMEOUT, ready) => match r {
|
||||
Ok(true) => Ok(Running { child, session_id: launch.session_id, output }),
|
||||
Ok(false) => {
|
||||
let _ = child.wait().await;
|
||||
Err(plugin_failed(&output))
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
Err(format!(
|
||||
"The SSM port forward did not come up within {} s ({})",
|
||||
READY_TIMEOUT.as_secs(),
|
||||
output.joined()
|
||||
))
|
||||
}
|
||||
},
|
||||
// Exited before (or without) printing the ready line.
|
||||
status = child.wait() => {
|
||||
// Let the drains catch the last lines before reporting them.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let _ = status;
|
||||
Err(plugin_failed(&output))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_failed(output: &OutputTail) -> String {
|
||||
match output.joined() {
|
||||
s if s.is_empty() => "The SSM port forward exited before it was ready".to_string(),
|
||||
s => format!("The SSM port forward exited before it was ready: {s}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(
|
||||
stream: impl AsyncRead + Unpin + Send + 'static,
|
||||
tail: OutputTail,
|
||||
ready: Option<watch::Sender<bool>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
log::debug!("session-manager-plugin: {line}");
|
||||
if let Some(tx) = &ready {
|
||||
if line.contains(READY_LINE) {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
}
|
||||
tail.push(line);
|
||||
}
|
||||
// `ready` drops here; a waiter sees the channel close.
|
||||
});
|
||||
}
|
||||
|
||||
// ── Opening sessions ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Opens and ends sessions. The real one calls SSM and runs the plugin; tests
|
||||
/// substitute a fake plugin without AWS.
|
||||
trait SessionOpener: Send + Sync + 'static {
|
||||
fn open(&self, local_port: u16) -> impl Future<Output = Result<Running, String>> + Send;
|
||||
fn terminate(&self, session_id: String) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
struct SsmOpener {
|
||||
sdk: SdkConfig,
|
||||
/// The credentials last handed to the plugin, reused until they near
|
||||
/// expiry. Each fetch from the provider can be a `credential_process`
|
||||
/// (an MFA or biometric prompt), an STS call or an SSO call, and the
|
||||
/// provider in `SdkConfig` is not the SDK's cached one.
|
||||
plugin_creds: tokio::sync::Mutex<Option<aws_sdk_ssm::config::Credentials>>,
|
||||
ssm: aws_sdk_ssm::Client,
|
||||
region: String,
|
||||
plugin: PathBuf,
|
||||
target: String,
|
||||
remote_port: u16,
|
||||
}
|
||||
|
||||
impl SessionOpener for SsmOpener {
|
||||
async fn open(&self, local_port: u16) -> Result<Running, String> {
|
||||
let out = self
|
||||
.ssm
|
||||
.start_session()
|
||||
.target(&self.target)
|
||||
.document_name(PORT_FORWARD_DOCUMENT)
|
||||
.parameters("portNumber", vec![self.remote_port.to_string()])
|
||||
.parameters("localPortNumber", vec![local_port.to_string()])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the SSM session: {}", sdk_error(&e)))?;
|
||||
let session_id = out.session_id().unwrap_or_default().to_string();
|
||||
// From here the session exists on AWS's side. If this future is
|
||||
// dropped before it returns — a close or drop racing a reconnect, a
|
||||
// caller timing out the first open — the guard ends the session.
|
||||
let guard = SessionGuard {
|
||||
ssm: self.ssm.clone(),
|
||||
session_id: Some(session_id.clone()),
|
||||
};
|
||||
let response = Zeroizing::new(
|
||||
serde_json::json!({
|
||||
"SessionId": session_id,
|
||||
"StreamUrl": out.stream_url().unwrap_or_default(),
|
||||
"TokenValue": out.token_value().unwrap_or_default(),
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let request = serde_json::json!({
|
||||
"Target": self.target,
|
||||
"DocumentName": PORT_FORWARD_DOCUMENT,
|
||||
"Parameters": {
|
||||
"portNumber": [self.remote_port.to_string()],
|
||||
"localPortNumber": [local_port.to_string()],
|
||||
},
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut env = vec![
|
||||
(RESPONSE_ENV.to_string(), response),
|
||||
("AWS_REGION".to_string(), Zeroizing::new(self.region.clone())),
|
||||
];
|
||||
env.extend(self.plugin_credentials().await);
|
||||
|
||||
let launch = PluginLaunch {
|
||||
program: self.plugin.clone(),
|
||||
// <response env var> <region> StartSession <profile> <request> <endpoint>:
|
||||
// what the AWS CLI passes. No profile: credentials come from env.
|
||||
args: vec![
|
||||
RESPONSE_ENV.to_string(),
|
||||
self.region.clone(),
|
||||
"StartSession".to_string(),
|
||||
String::new(),
|
||||
request,
|
||||
ssm_endpoint(&self.region),
|
||||
],
|
||||
env,
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
match spawn_plugin(launch).await {
|
||||
Ok(r) => {
|
||||
guard.disarm();
|
||||
Ok(r)
|
||||
}
|
||||
Err(e) => {
|
||||
// No plugin is attached to the session; don't leave it open
|
||||
// until the idle timeout.
|
||||
guard.terminate().await;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminate(&self, session_id: String) {
|
||||
terminate_session(&self.ssm, session_id).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminate_session(ssm: &aws_sdk_ssm::Client, session_id: String) {
|
||||
if session_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let call = ssm.terminate_session().session_id(&session_id).send();
|
||||
match tokio::time::timeout(TERMINATE_TIMEOUT, call).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => log::warn!("TerminateSession {session_id}: {}", sdk_error(&e)),
|
||||
Err(_) => log::warn!("TerminateSession {session_id}: timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a started session unless disarmed: on an error path explicitly, and
|
||||
/// on drop (a cancelled open) from a spawned task.
|
||||
struct SessionGuard {
|
||||
ssm: aws_sdk_ssm::Client,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionGuard {
|
||||
fn disarm(mut self) {
|
||||
self.session_id = None;
|
||||
}
|
||||
|
||||
async fn terminate(mut self) {
|
||||
if let Some(id) = self.session_id.take() {
|
||||
terminate_session(&self.ssm, id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(id) = self.session_id.take() {
|
||||
if let Ok(rt) = tokio::runtime::Handle::try_current() {
|
||||
let ssm = self.ssm.clone();
|
||||
rt.spawn(async move { terminate_session(&ssm, id).await });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long before expiry cached plugin credentials are refreshed. A plugin
|
||||
/// started with them may need them for a resume well after it starts.
|
||||
const PLUGIN_CREDS_MIN_REMAINING: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
impl SsmOpener {
|
||||
/// The caller's credentials as the plugin's environment, for its own
|
||||
/// `ResumeSession` calls. Without them a resume fails and the
|
||||
/// supervisor's full reconnect takes over, so a failure here is not fatal.
|
||||
async fn plugin_credentials(&self) -> Vec<(String, Zeroizing<String>)> {
|
||||
use aws_sdk_ssm::config::ProvideCredentials;
|
||||
let mut cached = self.plugin_creds.lock().await;
|
||||
let fresh = |c: &aws_sdk_ssm::config::Credentials| match c.expiry() {
|
||||
None => true,
|
||||
Some(exp) => exp
|
||||
.duration_since(std::time::SystemTime::now())
|
||||
.map(|left| left > PLUGIN_CREDS_MIN_REMAINING)
|
||||
.unwrap_or(false),
|
||||
};
|
||||
if !cached.as_ref().is_some_and(fresh) {
|
||||
*cached = None;
|
||||
if let Some(provider) = self.sdk.credentials_provider() {
|
||||
match provider.provide_credentials().await {
|
||||
Ok(c) => *cached = Some(c),
|
||||
Err(e) => log::warn!("No credentials for the SSM plugin's resume: {}", sdk_error(&e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(c) = cached.as_ref() else {
|
||||
return vec![];
|
||||
};
|
||||
let mut v = vec![
|
||||
("AWS_ACCESS_KEY_ID".to_string(), Zeroizing::new(c.access_key_id().to_string())),
|
||||
(
|
||||
"AWS_SECRET_ACCESS_KEY".to_string(),
|
||||
Zeroizing::new(c.secret_access_key().to_string()),
|
||||
),
|
||||
];
|
||||
if let Some(t) = c.session_token() {
|
||||
v.push(("AWS_SESSION_TOKEN".to_string(), Zeroizing::new(t.to_string())));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
fn ssm_endpoint(region: &str) -> String {
|
||||
let suffix = if region.starts_with("cn-") { "amazonaws.com.cn" } else { "amazonaws.com" };
|
||||
format!("https://ssm.{region}.{suffix}")
|
||||
}
|
||||
|
||||
/// A free loopback port, for the plugin to bind. The plugin binds it a moment
|
||||
/// later, so another process can take it in between; the first connection
|
||||
/// retries with a new port when that happens.
|
||||
fn free_local_port() -> Result<u16, String> {
|
||||
std::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.and_then(|l| l.local_addr())
|
||||
.map(|a| a.port())
|
||||
.map_err(|e| format!("Could not find a free local port: {e}"))
|
||||
}
|
||||
|
||||
// ── The port forward ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Where the tunnel is. Watch it with [`PortForward::state`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TunnelState {
|
||||
Up,
|
||||
/// The session dropped and is being reopened. `last_error` is why the
|
||||
/// previous one ended or the previous attempt failed.
|
||||
Reconnecting { attempt: u32, last_error: String },
|
||||
/// Reconnecting gave up. Nothing listens on the local port.
|
||||
Failed(String),
|
||||
/// [`PortForward::close`] ran, or the handle was dropped.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// One SSM port-forward to an agent, supervised: reopened on the same local
|
||||
/// port when it drops, ended (`TerminateSession`) when this handle is closed
|
||||
/// or dropped.
|
||||
pub struct PortForward {
|
||||
local_port: u16,
|
||||
state: watch::Receiver<TunnelState>,
|
||||
plugin_pid: watch::Receiver<Option<u32>>,
|
||||
supervisor: Option<tokio::task::JoinHandle<()>>,
|
||||
_cancel: DropGuard,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PortForward {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PortForward")
|
||||
.field("local_port", &self.local_port)
|
||||
.field("state", &*self.state.borrow())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PortForward {
|
||||
/// Open a port forward from a free loopback port to `remote_port` in
|
||||
/// `task`, and return once the local listener is up. Errors (no plugin,
|
||||
/// IAM denial, the task not reachable) come back here rather than as a
|
||||
/// state.
|
||||
pub async fn open(sdk: &SdkConfig, task: &EcsTask, remote_port: u16) -> Result<Self, String> {
|
||||
let region = sdk
|
||||
.region()
|
||||
.ok_or("No AWS region is configured for this connection")?
|
||||
.to_string();
|
||||
let opener = SsmOpener {
|
||||
sdk: sdk.clone(),
|
||||
plugin_creds: Default::default(),
|
||||
ssm: aws_sdk_ssm::Client::new(sdk),
|
||||
region,
|
||||
plugin: find_plugin()?,
|
||||
target: task.ssm_target(),
|
||||
remote_port,
|
||||
};
|
||||
Self::open_with(opener).await
|
||||
}
|
||||
|
||||
async fn open_with<O: SessionOpener>(opener: O) -> Result<Self, String> {
|
||||
let mut last_err = String::new();
|
||||
for _ in 0..INITIAL_PORT_ATTEMPTS {
|
||||
let port = free_local_port()?;
|
||||
match opener.open(port).await {
|
||||
Ok(running) => return Ok(Self::supervise(opener, port, running)),
|
||||
Err(e) if is_port_in_use(&e) => last_err = e,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
fn supervise<O: SessionOpener>(opener: O, local_port: u16, running: Running) -> Self {
|
||||
let (state_tx, state) = watch::channel(TunnelState::Up);
|
||||
let (pid_tx, plugin_pid) = watch::channel(running.child.id());
|
||||
let cancel = CancellationToken::new();
|
||||
let supervisor = tokio::spawn(run_supervisor(
|
||||
opener,
|
||||
local_port,
|
||||
running,
|
||||
state_tx,
|
||||
pid_tx,
|
||||
cancel.clone(),
|
||||
));
|
||||
Self {
|
||||
local_port,
|
||||
state,
|
||||
plugin_pid,
|
||||
supervisor: Some(supervisor),
|
||||
_cancel: cancel.clone().drop_guard(),
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_port(&self) -> u16 {
|
||||
self.local_port
|
||||
}
|
||||
|
||||
/// The agent at the local end of this tunnel. Stays valid across
|
||||
/// reconnects (the port does not change).
|
||||
pub fn endpoint(&self, token: impl Into<String>) -> AgentEndpoint {
|
||||
AgentEndpoint::loopback(self.local_port, token)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> watch::Receiver<TunnelState> {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
/// The running plugin's process id, if one is running.
|
||||
pub fn plugin_pid(&self) -> Option<u32> {
|
||||
*self.plugin_pid.borrow()
|
||||
}
|
||||
|
||||
/// Stop the plugin and end the session, and wait for both.
|
||||
pub async fn close(mut self) {
|
||||
self.cancel.cancel();
|
||||
if let Some(s) = self.supervisor.take() {
|
||||
let _ = s.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_port_in_use(e: &str) -> bool {
|
||||
let e = e.to_ascii_lowercase();
|
||||
e.contains("address already in use") || e.contains("only one usage of each socket address")
|
||||
}
|
||||
|
||||
async fn run_supervisor<O: SessionOpener>(
|
||||
opener: O,
|
||||
local_port: u16,
|
||||
mut running: Running,
|
||||
state: watch::Sender<TunnelState>,
|
||||
pid: watch::Sender<Option<u32>>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
loop {
|
||||
// ── Up: wait for the plugin to exit, or for close; keep it busy ──
|
||||
let mut keepalive = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + KEEPALIVE_INTERVAL,
|
||||
KEEPALIVE_INTERVAL,
|
||||
);
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let exited = loop {
|
||||
tokio::select! {
|
||||
s = running.child.wait() => break s,
|
||||
_ = keepalive.tick() => {
|
||||
tokio::spawn(keepalive_probe(local_port));
|
||||
}
|
||||
_ = cancel.cancelled() => {
|
||||
let _ = running.child.kill().await;
|
||||
opener.terminate(running.session_id).await;
|
||||
let _ = pid.send(None);
|
||||
let _ = state.send(TunnelState::Closed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = pid.send(None);
|
||||
// Let the drains catch the last lines.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// Both halves: SSM's own reason is in the output ("timed out due to
|
||||
// inactivity"), but after a kill the last line is just whatever the
|
||||
// plugin printed before, so the exit status has to be there too.
|
||||
let mut last_error = match running.output.last() {
|
||||
Some(line) => format!("the SSM session ended ({}; last output: {line})", describe_exit(exited)),
|
||||
None => format!("the SSM session ended ({})", describe_exit(exited)),
|
||||
};
|
||||
log::warn!("SSM port forward on 127.0.0.1:{local_port}: {last_error}; reconnecting");
|
||||
opener.terminate(running.session_id).await;
|
||||
|
||||
// ── Reconnecting ──
|
||||
let mut attempt = 0;
|
||||
running = loop {
|
||||
attempt += 1;
|
||||
if attempt > MAX_RECONNECT_ATTEMPTS {
|
||||
let _ = state.send(TunnelState::Failed(last_error));
|
||||
// Nothing to clean up; wait for the handle to go.
|
||||
cancel.cancelled().await;
|
||||
let _ = state.send(TunnelState::Closed);
|
||||
return;
|
||||
}
|
||||
let _ = state.send(TunnelState::Reconnecting {
|
||||
attempt,
|
||||
last_error: last_error.clone(),
|
||||
});
|
||||
let delay = reconnect_delay(attempt);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
_ = cancel.cancelled() => {
|
||||
let _ = state.send(TunnelState::Closed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let opened = tokio::select! {
|
||||
r = opener.open(local_port) => r,
|
||||
_ = cancel.cancelled() => {
|
||||
let _ = state.send(TunnelState::Closed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match opened {
|
||||
Ok(r) => break r,
|
||||
Err(e) => {
|
||||
log::warn!("SSM port forward on 127.0.0.1:{local_port}: reconnect {attempt}: {e}");
|
||||
last_error = e;
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = pid.send(running.child.id());
|
||||
let _ = state.send(TunnelState::Up);
|
||||
log::info!("SSM port forward on 127.0.0.1:{local_port}: reconnected after {attempt} attempt(s)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One request through the tunnel so SSM sees data: an *unauthenticated*
|
||||
/// `GET /v1/health`. The agent answers `401` before it counts anything as
|
||||
/// activity, so this keeps the session alive without keeping the workspace
|
||||
/// "in use" for idle auto-stop — and it needs no token here.
|
||||
async fn keepalive_probe(local_port: u16) {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let probe = async {
|
||||
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
s.write_all(b"GET /v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
|
||||
.await?;
|
||||
let mut sink = [0u8; 512];
|
||||
while s.read(&mut sink).await? > 0 {}
|
||||
Ok::<_, std::io::Error>(())
|
||||
};
|
||||
match tokio::time::timeout(KEEPALIVE_TIMEOUT, probe).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: {e}"),
|
||||
Err(_) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
fn reconnect_delay(attempt: u32) -> Duration {
|
||||
let d = RECONNECT_BACKOFF[(attempt as usize).saturating_sub(1).min(RECONNECT_BACKOFF.len() - 1)];
|
||||
if cfg!(test) {
|
||||
d / 100
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_exit(s: std::io::Result<std::process::ExitStatus>) -> String {
|
||||
match s {
|
||||
Ok(s) => format!("plugin {s}"),
|
||||
Err(e) => format!("plugin wait failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! The real thing: an SSM port forward to `triple-c-agent` in a running ECS
|
||||
//! task, used through `ContainerTarget::Agent` exactly as the app will.
|
||||
//!
|
||||
//! Ignored; needs AWS credentials (the default chain — profile, SSO or env),
|
||||
//! `session-manager-plugin`, and a task started with ECS Exec from an image
|
||||
//! that has the agent:
|
||||
//!
|
||||
//! - `TRIPLEC_SSM_E2E_CLUSTER`, `TRIPLEC_SSM_E2E_TASK` (name/id or ARN)
|
||||
//! - `TRIPLEC_SSM_E2E_TOKEN` — the agent token the task was given
|
||||
//! - `TRIPLEC_SSM_E2E_CONTAINER` — default `workspace`
|
||||
//! - `TRIPLEC_SSM_E2E_HOLD_MINUTES` — only for the hold test
|
||||
//!
|
||||
//! The SSM session's IAM gate is what these exercise against AWS; the agent's
|
||||
//! Docker parity is already pinned by `host::agent::live_tests`.
|
||||
|
||||
use super::*;
|
||||
use crate::host::{ContainerTarget, ExecChunk};
|
||||
use futures_util::StreamExt;
|
||||
use std::time::Instant;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
fn var(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
|
||||
}
|
||||
|
||||
async fn connect() -> (PortForward, ContainerTarget, Duration) {
|
||||
let sdk = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
||||
let ecs = aws_sdk_ecs::Client::new(&sdk);
|
||||
let container = std::env::var("TRIPLEC_SSM_E2E_CONTAINER").unwrap_or_else(|_| "workspace".into());
|
||||
let task = EcsTask::resolve(&ecs, &var("TRIPLEC_SSM_E2E_CLUSTER"), &var("TRIPLEC_SSM_E2E_TASK"), &container)
|
||||
.await
|
||||
.expect("resolve the task");
|
||||
let t0 = Instant::now();
|
||||
let pf = PortForward::open(&sdk, &task, AGENT_PORT).await.expect("open the port forward");
|
||||
let opened = t0.elapsed();
|
||||
let target = ContainerTarget::Agent(pf.endpoint(var("TRIPLEC_SSM_E2E_TOKEN")));
|
||||
(pf, target, opened)
|
||||
}
|
||||
|
||||
async fn wait_up(pf: &PortForward, within: Duration) -> Duration {
|
||||
let t0 = Instant::now();
|
||||
let mut s = pf.state();
|
||||
tokio::time::timeout(within, s.wait_for(|s| *s == TunnelState::Up))
|
||||
.await
|
||||
.expect("the tunnel did not come back")
|
||||
.unwrap();
|
||||
t0.elapsed()
|
||||
}
|
||||
|
||||
async fn whoami(t: &ContainerTarget) -> Result<String, String> {
|
||||
t.exec_oneshot(vec!["whoami".into()]).await.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_agent_works_through_an_ssm_port_forward_and_survives_a_drop() {
|
||||
let (pf, t, opened) = connect().await;
|
||||
eprintln!("port forward up in {opened:?} on 127.0.0.1:{}", pf.local_port());
|
||||
|
||||
// ── the operations the app uses ──
|
||||
assert!(t.is_running().await.unwrap());
|
||||
assert_eq!(whoami(&t).await.unwrap(), "claude");
|
||||
|
||||
let n = 20;
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..n {
|
||||
t.exec_oneshot(vec!["true".into()]).await.unwrap();
|
||||
}
|
||||
eprintln!("one-shot exec round trip: {:?} average over {n}", t0.elapsed() / n);
|
||||
|
||||
// attach with stdin
|
||||
let mut exec = t
|
||||
.attach(crate::host::ExecSpec::new(vec!["head".into(), "-n1".into()]))
|
||||
.await
|
||||
.unwrap();
|
||||
exec.input.write_all(b"through ssm\n").await.unwrap();
|
||||
exec.input.shutdown().await.unwrap();
|
||||
let mut out = Vec::new();
|
||||
while let Some(Ok(c)) = exec.output.next().await {
|
||||
if let ExecChunk::StdOut(b) = c {
|
||||
out.extend_from_slice(&b);
|
||||
}
|
||||
}
|
||||
assert_eq!(String::from_utf8_lossy(&out), "through ssm\n");
|
||||
|
||||
// 4 MiB up and back
|
||||
let data: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
|
||||
let t0 = Instant::now();
|
||||
t.upload_bytes_with_ids("/tmp", "ssm-e2e.bin", &data, 0o644, (1000, 1000)).await.unwrap();
|
||||
let up = t0.elapsed();
|
||||
let t0 = Instant::now();
|
||||
let mut tar = Vec::new();
|
||||
let mut s = t.download_archive("/tmp/ssm-e2e.bin");
|
||||
while let Some(chunk) = s.next().await {
|
||||
tar.extend_from_slice(&chunk.unwrap());
|
||||
}
|
||||
let down = t0.elapsed();
|
||||
let mut a = tar::Archive::new(&tar[..]);
|
||||
let mut e = a.entries().unwrap().next().unwrap().unwrap();
|
||||
let mut back = Vec::new();
|
||||
std::io::Read::read_to_end(&mut e, &mut back).unwrap();
|
||||
assert!(back == data, "the file came back different");
|
||||
eprintln!("4 MiB: upload {up:?}, download {down:?}");
|
||||
t.exec_oneshot(vec!["rm".into(), "-f".into(), "/tmp/ssm-e2e.bin".into()]).await.unwrap();
|
||||
|
||||
// ── a dropped session: kill the plugin, as a crash or sleep would ──
|
||||
let pid = pf.plugin_pid().unwrap();
|
||||
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap();
|
||||
let mut s = pf.state();
|
||||
s.wait_for(|s| *s != TunnelState::Up).await.unwrap();
|
||||
let back_in = wait_up(&pf, Duration::from_secs(60)).await;
|
||||
eprintln!("reconnected in {back_in:?} (same port {})", pf.local_port());
|
||||
assert_eq!(whoami(&t).await.unwrap(), "claude", "the same endpoint works after a reconnect");
|
||||
|
||||
// ── close ends it ──
|
||||
let port = pf.local_port();
|
||||
pf.close().await;
|
||||
assert!(
|
||||
std::net::TcpStream::connect(("127.0.0.1", port)).is_err(),
|
||||
"something still listens on the closed tunnel's port"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hold one tunnel for `TRIPLEC_SSM_E2E_HOLD_MINUTES` (default 60): a probe
|
||||
/// every minute for the first and last stretch, and a silent stretch in the
|
||||
/// middle longer than Session Manager's default 20-minute idle timeout. One
|
||||
/// attached exec stays open the whole time. Prints a timeline of tunnel
|
||||
/// states; fails only if the tunnel cannot serve a request at the end.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn an_ssm_port_forward_holds_for_a_long_session() {
|
||||
let minutes: u64 = std::env::var("TRIPLEC_SSM_E2E_HOLD_MINUTES")
|
||||
.ok()
|
||||
.and_then(|m| m.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let (pf, t, opened) = connect().await;
|
||||
let start = Instant::now();
|
||||
let stamp = move || format!("[{:>5.1} min]", start.elapsed().as_secs_f64() / 60.0);
|
||||
eprintln!("{} up in {opened:?}", stamp());
|
||||
|
||||
// Log every state change as it happens.
|
||||
let mut states = pf.state();
|
||||
let log_states = tokio::spawn(async move {
|
||||
while states.changed().await.is_ok() {
|
||||
eprintln!("{} tunnel: {:?}", stamp(), *states.borrow());
|
||||
}
|
||||
});
|
||||
|
||||
// A long-lived connection: does one TCP stream survive the whole hold?
|
||||
let mut held = t.attach(crate::host::ExecSpec::new(vec!["cat".into()])).await.unwrap();
|
||||
|
||||
let idle_from = minutes / 4;
|
||||
let idle_to = idle_from + 25.min(minutes / 2);
|
||||
let mut failures = 0;
|
||||
for m in 0..minutes {
|
||||
if m < idle_from || m >= idle_to {
|
||||
let t0 = Instant::now();
|
||||
match whoami(&t).await {
|
||||
Ok(_) => eprintln!("{} probe ok ({:?})", stamp(), t0.elapsed()),
|
||||
Err(e) => {
|
||||
failures += 1;
|
||||
eprintln!("{} probe FAILED: {e}", stamp())
|
||||
}
|
||||
}
|
||||
} else if m == idle_from {
|
||||
eprintln!("{} silent until minute {idle_to}", stamp());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
|
||||
// Did the held stream survive? Echo through it.
|
||||
let survived = async {
|
||||
held.input.write_all(b"still here\n").await.ok()?;
|
||||
let mut got = Vec::new();
|
||||
while let Ok(Some(Ok(c))) = tokio::time::timeout(Duration::from_secs(10), held.output.next()).await {
|
||||
if let ExecChunk::StdOut(b) = c {
|
||||
got.extend_from_slice(&b);
|
||||
if got.ends_with(b"\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(got == b"still here\n")
|
||||
}
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
eprintln!("{} long-lived attach survived: {survived}; probe failures: {failures}", stamp());
|
||||
|
||||
assert_eq!(whoami(&t).await.unwrap(), "claude", "the tunnel serves at the end");
|
||||
pf.close().await;
|
||||
log_states.abort();
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! The plugin handling and the supervisor, against a fake plugin (a shell
|
||||
//! script) instead of AWS. The real thing is in `live_tests.rs`.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_ssm_target_names_cluster_task_and_container_runtime() {
|
||||
let t = EcsTask {
|
||||
cluster: "triple-c".into(),
|
||||
task_id: "9fb3a6cf".into(),
|
||||
runtime_id: "9fb3a6cf-0791508406".into(),
|
||||
};
|
||||
assert_eq!(t.ssm_target(), "ecs:triple-c_9fb3a6cf_9fb3a6cf-0791508406");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arns_are_reduced_to_their_names() {
|
||||
assert_eq!(
|
||||
last_arn_segment("arn:aws:ecs:us-west-2:123456789012:cluster/triple-c", "cluster/"),
|
||||
"triple-c"
|
||||
);
|
||||
assert_eq!(
|
||||
last_arn_segment("arn:aws:ecs:us-west-2:123456789012:task/triple-c/9fb3a6cf", "/"),
|
||||
"9fb3a6cf"
|
||||
);
|
||||
assert_eq!(last_arn_segment("triple-c", "cluster/"), "triple-c");
|
||||
assert_eq!(last_arn_segment("9fb3a6cf", "/"), "9fb3a6cf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plugin_endpoint_follows_the_partition() {
|
||||
assert_eq!(ssm_endpoint("us-west-2"), "https://ssm.us-west-2.amazonaws.com");
|
||||
assert_eq!(ssm_endpoint("cn-north-1"), "https://ssm.cn-north-1.amazonaws.com.cn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_port_clash_is_recognised_on_every_platform() {
|
||||
assert!(is_port_in_use("listen tcp 127.0.0.1:4: bind: address already in use"));
|
||||
assert!(is_port_in_use(
|
||||
"bind: Only one usage of each socket address (protocol/network address/port) is normally permitted."
|
||||
));
|
||||
assert!(!is_port_in_use("TargetNotConnected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_grows_then_holds() {
|
||||
assert!(reconnect_delay(1) < reconnect_delay(2));
|
||||
assert_eq!(reconnect_delay(5), reconnect_delay(500));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod with_a_fake_plugin {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// A fake `session-manager-plugin`: a shell script.
|
||||
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Runs the script as `/bin/sh <script>`: the script is only ever read,
|
||||
/// never exec'd, so a parallel test's fork holding it open for writing
|
||||
/// for a moment cannot fail the spawn with ETXTBSY ("Text file busy").
|
||||
fn launch(script: PathBuf, secret: &str) -> PluginLaunch {
|
||||
PluginLaunch {
|
||||
program: "/bin/sh".into(),
|
||||
args: vec![
|
||||
script.display().to_string(),
|
||||
RESPONSE_ENV.into(),
|
||||
"us-west-2".into(),
|
||||
"StartSession".into(),
|
||||
],
|
||||
env: vec![(RESPONSE_ENV.into(), Zeroizing::new(secret.into()))],
|
||||
session_id: "s-1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
const READY: &str = "echo 'Port 1 opened for sessionId s.'; echo 'Waiting for connections...'";
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_session_token_travels_in_the_environment_never_on_the_command_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("seen");
|
||||
// Nothing AWS-shaped from our own environment may reach the plugin.
|
||||
// (A name no other test reads, so setting it here is not a race.)
|
||||
std::env::set_var("AWS_TRIPLEC_SSM_TEST_LEAK", "leaked");
|
||||
let p = script(
|
||||
dir.path(),
|
||||
"plugin",
|
||||
&format!(
|
||||
"echo \"argv=$*\" > {o}; echo \"env=${r}\" >> {o}; echo \"leak=$AWS_TRIPLEC_SSM_TEST_LEAK\" >> {o}; {READY}; exec sleep 30",
|
||||
o = out.display(),
|
||||
r = RESPONSE_ENV
|
||||
),
|
||||
);
|
||||
let running = spawn_plugin(launch(p, "the-secret-token")).await.expect("ready");
|
||||
let seen = std::fs::read_to_string(&out).unwrap();
|
||||
std::env::remove_var("AWS_TRIPLEC_SSM_TEST_LEAK");
|
||||
assert!(seen.contains("env=the-secret-token"), "{seen}");
|
||||
let argv = seen.lines().find(|l| l.starts_with("argv=")).unwrap();
|
||||
assert!(!argv.contains("the-secret-token"), "{argv}");
|
||||
assert!(argv.contains(RESPONSE_ENV), "the plugin is told which variable to read: {argv}");
|
||||
assert!(seen.contains("leak=\n"), "{seen}");
|
||||
drop(running); // kill_on_drop
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plugin_that_exits_early_says_why() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(
|
||||
dir.path(),
|
||||
"plugin",
|
||||
"echo 'An error occurred (TargetNotConnected) when calling the StartSession operation' >&2; exit 255",
|
||||
);
|
||||
let err = spawn_plugin(launch(p, "x")).await.err().expect("must fail");
|
||||
assert!(err.contains("TargetNotConnected"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plugin_that_never_gets_ready_is_killed_and_reported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", "echo 'Starting session'; exec sleep 30");
|
||||
let err = spawn_plugin(launch(p, "x")).await.err().expect("must time out");
|
||||
assert!(err.contains("did not come up"), "{err}");
|
||||
assert!(err.contains("Starting session"), "{err}");
|
||||
}
|
||||
|
||||
/// Opens sessions by running a fake plugin; records what it was asked.
|
||||
struct FakeOpener {
|
||||
plugin: PathBuf,
|
||||
ports: Arc<Mutex<Vec<u16>>>,
|
||||
terminated: Arc<Mutex<Vec<String>>>,
|
||||
opened: Arc<AtomicU32>,
|
||||
/// Fail opens whose 1-based number is in here, with this message.
|
||||
fail: Vec<(u32, &'static str)>,
|
||||
/// Connections made to the local port (the fake plugin listens on
|
||||
/// nothing, so the opener stands in for its listener).
|
||||
connections: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl FakeOpener {
|
||||
fn new(plugin: PathBuf) -> Self {
|
||||
Self {
|
||||
plugin,
|
||||
ports: Default::default(),
|
||||
terminated: Default::default(),
|
||||
opened: Default::default(),
|
||||
fail: vec![],
|
||||
connections: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionOpener for FakeOpener {
|
||||
async fn open(&self, local_port: u16) -> Result<Running, String> {
|
||||
let n = self.opened.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.ports.lock().unwrap().push(local_port);
|
||||
if let Some((_, msg)) = self.fail.iter().find(|(i, _)| *i == n) {
|
||||
return Err(msg.to_string());
|
||||
}
|
||||
if let Ok(listener) = tokio::net::TcpListener::bind(("127.0.0.1", local_port)).await {
|
||||
let count = self.connections.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
drop(sock);
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut l = launch(self.plugin.clone(), "x");
|
||||
l.session_id = format!("s-{n}");
|
||||
spawn_plugin(l).await
|
||||
}
|
||||
|
||||
async fn terminate(&self, session_id: String) {
|
||||
self.terminated.lock().unwrap().push(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for(state: &mut watch::Receiver<TunnelState>, want: impl Fn(&TunnelState) -> bool) -> TunnelState {
|
||||
let s = tokio::time::timeout(Duration::from_secs(10), state.wait_for(|s| want(s)))
|
||||
.await
|
||||
.expect("state did not arrive")
|
||||
.expect("supervisor gone")
|
||||
.clone();
|
||||
s
|
||||
}
|
||||
|
||||
fn alive(pid: u32) -> bool {
|
||||
std::process::Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn kill(pid: u32) {
|
||||
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dropped_session_is_reopened_on_the_same_local_port() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let opener = FakeOpener::new(p);
|
||||
let (ports, terminated) = (opener.ports.clone(), opener.terminated.clone());
|
||||
|
||||
let pf = PortForward::open_with(opener).await.expect("open");
|
||||
let mut state = pf.state();
|
||||
assert_eq!(*state.borrow(), TunnelState::Up);
|
||||
let first = pf.plugin_pid().expect("a plugin runs");
|
||||
|
||||
kill(first);
|
||||
wait_for(&mut state, |s| matches!(s, TunnelState::Reconnecting { .. })).await;
|
||||
wait_for(&mut state, |s| *s == TunnelState::Up).await;
|
||||
let second = pf.plugin_pid().expect("a new plugin runs");
|
||||
assert_ne!(first, second);
|
||||
|
||||
let ports = ports.lock().unwrap().clone();
|
||||
assert_eq!(ports.len(), 2, "{ports:?}");
|
||||
assert_eq!(ports[0], ports[1], "the endpoint must stay valid: same port");
|
||||
assert_eq!(pf.local_port(), ports[0]);
|
||||
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()], "the dead session is ended");
|
||||
|
||||
pf.close().await;
|
||||
assert!(!alive(second));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_up_tunnel_keeps_itself_busy() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let opener = FakeOpener::new(p);
|
||||
let connections = opener.connections.clone();
|
||||
let pf = PortForward::open_with(opener).await.expect("open");
|
||||
tokio::time::sleep(KEEPALIVE_INTERVAL * 5 + KEEPALIVE_INTERVAL / 2).await;
|
||||
let n = connections.load(Ordering::SeqCst);
|
||||
assert!((3..=6).contains(&n), "{n} keepalives in 5.5 intervals");
|
||||
pf.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_kills_the_plugin_and_ends_the_session() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let opener = FakeOpener::new(p);
|
||||
let terminated = opener.terminated.clone();
|
||||
let pf = PortForward::open_with(opener).await.expect("open");
|
||||
let pid = pf.plugin_pid().unwrap();
|
||||
let mut state = pf.state();
|
||||
pf.close().await;
|
||||
assert_eq!(*state.borrow_and_update(), TunnelState::Closed);
|
||||
assert!(!alive(pid), "the plugin outlived close()");
|
||||
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_the_handle_also_ends_the_session() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let opener = FakeOpener::new(p);
|
||||
let terminated = opener.terminated.clone();
|
||||
let pf = PortForward::open_with(opener).await.expect("open");
|
||||
let pid = pf.plugin_pid().unwrap();
|
||||
let mut state = pf.state();
|
||||
drop(pf);
|
||||
wait_for(&mut state, |s| *s == TunnelState::Closed).await;
|
||||
assert!(!alive(pid));
|
||||
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_first_open_moves_off_a_port_someone_else_took() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let mut opener = FakeOpener::new(p);
|
||||
opener.fail = vec![(1, "listen tcp 127.0.0.1:1: bind: address already in use")];
|
||||
let opened = opener.opened.clone();
|
||||
let pf = PortForward::open_with(opener).await.expect("second port works");
|
||||
assert_eq!(opened.load(Ordering::SeqCst), 2);
|
||||
pf.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_first_open_errors_are_returned_not_retried() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let mut opener = FakeOpener::new(p);
|
||||
opener.fail = vec![(1, "AccessDeniedException: not authorized to perform ssm:StartSession")];
|
||||
let opened = opener.opened.clone();
|
||||
let err = PortForward::open_with(opener).await.err().expect("must fail");
|
||||
assert!(err.contains("AccessDenied"), "{err}");
|
||||
assert_eq!(opened.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnecting_gives_up_after_the_limit_and_says_why() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let mut opener = FakeOpener::new(p);
|
||||
// Open 1 works; every reconnect after it fails.
|
||||
opener.fail = (2..=MAX_RECONNECT_ATTEMPTS + 1)
|
||||
.map(|i| (i, "TargetNotConnected: the task stopped"))
|
||||
.collect();
|
||||
let pf = PortForward::open_with(opener).await.expect("open");
|
||||
let mut state = pf.state();
|
||||
kill(pf.plugin_pid().unwrap());
|
||||
let s = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
state.wait_for(|s| matches!(s, TunnelState::Failed(_))),
|
||||
)
|
||||
.await
|
||||
.expect("gave up")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(s, TunnelState::Failed("TargetNotConnected: the task stopped".into()));
|
||||
assert_eq!(pf.plugin_pid(), None);
|
||||
pf.close().await;
|
||||
assert_eq!(*state.borrow_and_update(), TunnelState::Closed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancelled open (its future dropped after `StartSession` succeeded) must
|
||||
/// still end the session: the guard's drop sends `TerminateSession`. Checked
|
||||
/// against a fake SSM endpoint that records what it is asked.
|
||||
#[tokio::test]
|
||||
async fn a_dropped_open_still_ends_its_session() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let tx = seen_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut req = Vec::new();
|
||||
// Headers and the small JSON body arrive together here.
|
||||
while let Ok(n) = sock.read(&mut buf).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
req.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&req);
|
||||
if text.contains("SessionId") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(String::from_utf8_lossy(&req).into_owned());
|
||||
let body = r#"{"SessionId":"s-dropped"}"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/x-amz-json-1.1\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let conf = aws_sdk_ssm::Config::builder()
|
||||
.behavior_version(aws_sdk_ssm::config::BehaviorVersion::latest())
|
||||
.region(aws_sdk_ssm::config::Region::new("us-west-2"))
|
||||
.credentials_provider(aws_sdk_ssm::config::Credentials::new("AKIDTEST", "secret", None, None, "test"))
|
||||
.endpoint_url(format!("http://127.0.0.1:{port}"))
|
||||
.build();
|
||||
let guard = SessionGuard {
|
||||
ssm: aws_sdk_ssm::Client::from_conf(conf),
|
||||
session_id: Some("s-dropped".into()),
|
||||
};
|
||||
drop(guard);
|
||||
let req = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv())
|
||||
.await
|
||||
.expect("no TerminateSession arrived")
|
||||
.unwrap();
|
||||
assert!(req.contains("AmazonSSM.TerminateSession"), "{req}");
|
||||
assert!(req.contains("s-dropped"), "{req}");
|
||||
}
|
||||
|
||||
/// The keepalive is the unauthenticated health probe: some traffic, no token.
|
||||
#[tokio::test]
|
||||
async fn the_keepalive_asks_for_health_without_a_token() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = sock.read(&mut buf).await.unwrap();
|
||||
sock.write_all(b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\n\r\n").await.unwrap();
|
||||
String::from_utf8_lossy(&buf[..n]).into_owned()
|
||||
});
|
||||
keepalive_probe(port).await;
|
||||
let req = server.await.unwrap();
|
||||
assert!(req.starts_with("GET /v1/health HTTP/1.1\r\n"), "{req}");
|
||||
assert!(!req.to_ascii_lowercase().contains("authorization"), "{req}");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Where `container_watch` learns that a project container stopped on its
|
||||
//! own. Host-neutral shape: a reachability check, a stream of containers
|
||||
//! reported dead (replayable from a point in time), and a positive-exit check
|
||||
//! used to confirm each report before anything changes.
|
||||
//!
|
||||
//! Docker pushes `die` events. An AWS host will have no push stream from the
|
||||
//! app's point of view; its [`deaths`] will be a `DescribeTasks` poll that
|
||||
//! yields stopped tasks, which `container_watch` handles the same way because
|
||||
//! every report is confirmed with [`ContainerTarget::has_exited`] anyway.
|
||||
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::pin::Pin;
|
||||
|
||||
use super::ContainerTarget;
|
||||
use crate::docker;
|
||||
|
||||
/// Which host's deaths to watch. One variant today.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExitSource {
|
||||
Docker,
|
||||
}
|
||||
|
||||
pub type DeathStream = Pin<Box<dyn Stream<Item = Result<ContainerTarget, String>> + Send>>;
|
||||
|
||||
impl ExitSource {
|
||||
/// Whether the host answers at all. A `false` here is a failed connection
|
||||
/// attempt for the watcher's backoff.
|
||||
pub async fn reachable(&self) -> bool {
|
||||
match self {
|
||||
ExitSource::Docker => docker::events::ping().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Containers reported dead, replayed from `since` (unix seconds). An `Err`
|
||||
/// item, or the stream ending, means the connection is gone.
|
||||
pub fn deaths(&self, since: i64) -> DeathStream {
|
||||
match self {
|
||||
ExitSource::Docker => Box::pin(
|
||||
docker::events::container_deaths(since).map(|r| r.map(ContainerTarget::docker)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerTarget {
|
||||
/// `true` only when the host positively says the container is not running
|
||||
/// (stopped, or gone). Any failure to find out is `false` — unlike
|
||||
/// [`ContainerTarget::is_running`], whose callers want errors to read as
|
||||
/// "not running".
|
||||
pub async fn has_exited(&self) -> bool {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => docker::events::has_exited(container_id).await,
|
||||
// The agent can only say "I answer" or "I don't"; silence is not a
|
||||
// positive exit (an SSM blip looks the same). Only the host that
|
||||
// runs the container can confirm a death — an AWS host answers
|
||||
// from ECS — so through the agent alone this never confirms one.
|
||||
ContainerTarget::Agent(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
mod auth_bridge;
|
||||
mod aws;
|
||||
mod browser_view;
|
||||
#[cfg(test)]
|
||||
mod command_census;
|
||||
mod commands;
|
||||
mod container_watch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_media;
|
||||
mod docker;
|
||||
mod host;
|
||||
pub mod file_viewer;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
@@ -19,7 +24,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use auth_bridge::AuthBridgeManager;
|
||||
use docker::exec::ExecSessionManager;
|
||||
use host::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
use storage::settings_store::SettingsStore;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
@@ -206,6 +211,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// The detached OpenTofu runner (`aws::tofu`): `Some(exit code)` when this
|
||||
/// process was started as one, `None` for a normal launch. `main` asks
|
||||
/// before anything Tauri or GTK starts, so a runner never opens a window.
|
||||
pub fn tofu_runner_main() -> Option<i32> {
|
||||
aws::tofu::runner_main(std::env::args_os())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
logging::init();
|
||||
|
||||
@@ -254,6 +266,9 @@ pub fn run() {
|
||||
})
|
||||
.manage(file_viewer::registry::ViewerRegistry::default())
|
||||
.setup(move |app| {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_media::enable_microphone(app.handle());
|
||||
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
Ok(icon) => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -307,6 +322,16 @@ pub fn run() {
|
||||
.await;
|
||||
});
|
||||
|
||||
// Notice a project container that stops without the app asking
|
||||
// (its keepalive killed, an OOM, a `docker stop` from a shell) and
|
||||
// flip the project to Stopped. One daemon-side-filtered event
|
||||
// subscription for the app's lifetime — see `container_watch`.
|
||||
lifecycle_setup.track(container_watch::spawn(
|
||||
app.handle().clone(),
|
||||
projects_store_setup.clone(),
|
||||
lifecycle_setup.cancellation(),
|
||||
));
|
||||
|
||||
// Marketplaces: refresh each once at startup, in the background.
|
||||
// Failures are logged, not toasted — the Marketplace tab shows them.
|
||||
{
|
||||
@@ -576,6 +601,27 @@ pub fn run() {
|
||||
commands::settings_commands::detect_aws_config,
|
||||
commands::settings_commands::inspect_ca_cert_path,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
// AWS host connections
|
||||
commands::host_commands::add_aws_profile_connection,
|
||||
commands::host_commands::add_aws_keys_connection,
|
||||
commands::host_commands::update_aws_connection,
|
||||
commands::host_commands::remove_host_connection,
|
||||
commands::host_commands::aws_connection_identity,
|
||||
commands::host_commands::aws_connection_sso_login,
|
||||
commands::host_commands::check_aws_permissions,
|
||||
commands::host_commands::aws_policy_document,
|
||||
commands::environment_commands::detect_aws_environments,
|
||||
commands::environment_commands::deploy_aws_environment,
|
||||
commands::environment_commands::update_aws_environment,
|
||||
commands::environment_commands::repair_aws_environment,
|
||||
commands::environment_commands::destroy_incomplete_aws_environment,
|
||||
commands::environment_commands::unlock_aws_state,
|
||||
commands::environment_commands::follow_aws_deployment,
|
||||
commands::environment_commands::cancel_aws_deployment,
|
||||
commands::environment_commands::push_aws_saved_state,
|
||||
commands::environment_commands::onboard_aws_user,
|
||||
commands::environment_commands::deploy_aws_permissions_boundary,
|
||||
commands::environment_commands::save_aws_admin_bundle,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Microphone access for the main window on Linux (WebKitGTK).
|
||||
//!
|
||||
//! WebKitGTK denies `getUserMedia` unless the embedder (a) has the
|
||||
//! `enable-media-stream` setting on and (b) answers the webview's
|
||||
//! `permission-request` signal. wry does neither, so without this the speech-
|
||||
//! to-text button fails with
|
||||
//! `NotAllowedError: The request is not allowed by the user agent ...` even
|
||||
//! when GStreamer can see the microphone.
|
||||
//!
|
||||
//! Scope is deliberately narrow:
|
||||
//! * only the `main` window — the file-viewer windows and any embedded
|
||||
//! browser view load different content and get no grant;
|
||||
//! * only audio capture — a request that also asks for a camera, and every
|
||||
//! other permission type (geolocation, notifications, ...), is denied.
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
use webkit2gtk::glib::prelude::*;
|
||||
use webkit2gtk::{
|
||||
PermissionRequestExt, SettingsExt, UserMediaPermissionRequest, UserMediaPermissionRequestExt,
|
||||
WebView, WebViewExt,
|
||||
};
|
||||
|
||||
/// Whether a media permission request should be granted: microphone only.
|
||||
pub(crate) fn allow_media_request(for_audio: bool, for_video: bool) -> bool {
|
||||
for_audio && !for_video
|
||||
}
|
||||
|
||||
pub fn enable_microphone(app: &AppHandle) {
|
||||
let Some(window) = app.get_webview_window("main") else {
|
||||
log::warn!("Microphone: no main window, capture will stay disabled");
|
||||
return;
|
||||
};
|
||||
let result = window.with_webview(|platform| {
|
||||
let webview: WebView = platform.inner();
|
||||
if let Some(settings) = WebViewExt::settings(&webview) {
|
||||
settings.set_enable_media_stream(true);
|
||||
}
|
||||
webview.connect_permission_request(|_, request| {
|
||||
if let Some(media) = request.downcast_ref::<UserMediaPermissionRequest>() {
|
||||
let ok =
|
||||
allow_media_request(media.is_for_audio_device(), media.is_for_video_device());
|
||||
if ok {
|
||||
request.allow();
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
log::info!(
|
||||
"Microphone: media permission request {}",
|
||||
if ok { "allowed" } else { "denied" }
|
||||
);
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
true // handled; do not fall through to WebKit's default
|
||||
});
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::warn!("Microphone: could not configure the webview: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::allow_media_request;
|
||||
|
||||
#[test]
|
||||
fn grants_audio_only() {
|
||||
assert!(allow_media_request(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_camera_and_mixed_requests() {
|
||||
assert!(!allow_media_request(false, true));
|
||||
assert!(!allow_media_request(true, true));
|
||||
assert!(!allow_media_request(false, false));
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// A detached OpenTofu apply re-executes this binary with `--tofu-runner`
|
||||
// (aws/tofu.rs). It must not get anywhere near Tauri, GTK or a window.
|
||||
if let Some(code) = triple_c_lib::tofu_runner_main() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
// Before *any* `std::env::set_var` — `url_open` hands a child process the
|
||||
// environment this app was started with, and the workaround below is one
|
||||
// of the things that must not leak into it (see triple-c#34). Anything
|
||||
|
||||
@@ -5,16 +5,13 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY};
|
||||
use crate::docker::exec::{
|
||||
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
|
||||
};
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecChunk, ExecSpec};
|
||||
|
||||
pub const CODE_EVENT: &str = "marketplace-gh-login-code";
|
||||
pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output";
|
||||
@@ -32,8 +29,8 @@ const MAX_PENDING_LINE: usize = 4096;
|
||||
/// script around it (whose text contains the same words); errors are ignored.
|
||||
const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"];
|
||||
|
||||
/// Constant script; the host is `$1` (argv, never interpolated), because
|
||||
/// `create_attached_exec_as` takes no env.
|
||||
/// Constant script; the host is `$1` (argv, never interpolated); the attached
|
||||
/// exec is opened with no extra env.
|
||||
///
|
||||
/// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit,
|
||||
/// so the container is never left logged in. The `HUP INT TERM` trap turns a
|
||||
@@ -150,9 +147,9 @@ fn failure_tail(transcript: &str) -> String {
|
||||
|
||||
/// Pre-flight N9 / review fix 1: stop the in-container login after any
|
||||
/// failed attempt.
|
||||
async fn kill_container_login(container_id: &str) {
|
||||
async fn kill_container_login(target: &ContainerTarget) {
|
||||
let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect();
|
||||
let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await;
|
||||
let _ = target.exec_oneshot_as("claude", cmd, vec![]).await;
|
||||
}
|
||||
|
||||
/// Hand `result` back, running `cleanup` first when it is a failure.
|
||||
@@ -187,7 +184,7 @@ async fn drive_login<S, W, E>(
|
||||
mut emit: E,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
S: Stream<Item = Result<LogOutput, bollard::errors::Error>> + Unpin,
|
||||
S: Stream<Item = Result<ExecChunk, String>> + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
E: FnMut(&'static str, serde_json::Value),
|
||||
{
|
||||
@@ -262,7 +259,7 @@ where
|
||||
pub async fn run_gh_container_login(
|
||||
app: &AppHandle,
|
||||
account_id: &str,
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
host: &str,
|
||||
mut cancel: oneshot::Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
@@ -273,20 +270,20 @@ pub async fn run_gh_container_login(
|
||||
exec_id,
|
||||
output,
|
||||
input,
|
||||
} = create_attached_exec_as(
|
||||
container_id,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
GH_LOGIN_SCRIPT.to_string(),
|
||||
"triple-c-gh-login".to_string(),
|
||||
host.to_string(),
|
||||
],
|
||||
true,
|
||||
"claude",
|
||||
"/home/claude",
|
||||
)
|
||||
.await?;
|
||||
} = target
|
||||
.attach(
|
||||
ExecSpec::new(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
GH_LOGIN_SCRIPT.to_string(),
|
||||
"triple-c-gh-login".to_string(),
|
||||
host.to_string(),
|
||||
])
|
||||
.tty(true)
|
||||
.user("claude")
|
||||
.working_dir(Some("/home/claude")),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
|
||||
let result = async {
|
||||
@@ -305,7 +302,7 @@ pub async fn run_gh_container_login(
|
||||
if let Some(token) = extract_token(&transcript) {
|
||||
return Ok(token);
|
||||
}
|
||||
let status = wait_for_exec_exit(&exec_id).await;
|
||||
let status = target.wait_exec_exit(&exec_id).await;
|
||||
Err(format!(
|
||||
"gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}",
|
||||
status
|
||||
@@ -315,7 +312,7 @@ pub async fn run_gh_container_login(
|
||||
))
|
||||
}
|
||||
.await;
|
||||
cleanup_on_error(result, || kill_container_login(container_id)).await
|
||||
cleanup_on_error(result, || kill_container_login(target)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -496,23 +493,19 @@ mod tests {
|
||||
/// successful one does not.
|
||||
mod teardown {
|
||||
use super::super::*;
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
type Frame = Result<LogOutput, bollard::errors::Error>;
|
||||
type Frame = Result<ExecChunk, String>;
|
||||
|
||||
fn out(s: &'static str) -> Frame {
|
||||
Ok(LogOutput::StdOut { message: s.into() })
|
||||
Ok(ExecChunk::StdOut(s.into()))
|
||||
}
|
||||
|
||||
fn lost() -> Frame {
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 500,
|
||||
message: "connection reset".to_string(),
|
||||
})
|
||||
Err("connection reset".to_string())
|
||||
}
|
||||
|
||||
/// Records every write separately; or fails every write.
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::models::marketplace::{
|
||||
effective_installs, CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot,
|
||||
SyncReport,
|
||||
};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{AppSettings, Project};
|
||||
use catalog::{item_fingerprint, parse_catalog};
|
||||
use tree::GitTree;
|
||||
@@ -505,7 +506,7 @@ pub async fn sync_project(
|
||||
mgr: &MarketplaceManager,
|
||||
settings: &AppSettings,
|
||||
project: &Project,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> SyncReport {
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let _sync_guard = lock.lock().await;
|
||||
@@ -533,7 +534,7 @@ pub async fn sync_project(
|
||||
items,
|
||||
p.skipped.len()
|
||||
);
|
||||
let result = sync::sync_container(container_id, &p).await;
|
||||
let result = sync::sync_container(container, &p).await;
|
||||
sync::with_payload_skips(sync::report_from_result(result), &p.skipped)
|
||||
}
|
||||
Err(e) => sync::report_from_result(Err(e)),
|
||||
@@ -556,13 +557,16 @@ pub fn spawn_project_sync(
|
||||
mgr: Arc<MarketplaceManager>,
|
||||
settings: AppSettings,
|
||||
project: Project,
|
||||
container_id: String,
|
||||
// The target the start just produced — not `ContainerTarget::for_project`:
|
||||
// `project.container_id` is written after this is called, so it could name
|
||||
// a container that was just replaced, or none at all.
|
||||
container: ContainerTarget,
|
||||
) {
|
||||
if !should_sync(&mgr, &settings, &project) {
|
||||
return;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let report = sync_project(&mgr, &settings, &project, &container_id).await;
|
||||
let report = sync_project(&mgr, &settings, &project, &container).await;
|
||||
if !report.errors.is_empty() {
|
||||
log::warn!(
|
||||
"Marketplace sync for project {} reported errors: {:?}",
|
||||
@@ -1132,9 +1136,10 @@ mod tests {
|
||||
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let guard = lock.lock().await;
|
||||
let nowhere = ContainerTarget::docker("no-such-container");
|
||||
let blocked = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(300),
|
||||
sync_project(&mgr, &settings, &project, "no-such-container"),
|
||||
sync_project(&mgr, &settings, &project, &nowhere),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "sync must wait while another sync holds the lock");
|
||||
@@ -1143,7 +1148,7 @@ mod tests {
|
||||
|
||||
// No Docker (or no such container) here: the failure becomes a stored
|
||||
// report instead of an error.
|
||||
let report = sync_project(&mgr, &settings, &project, "no-such-container").await;
|
||||
let report = sync_project(&mgr, &settings, &project, &nowhere).await;
|
||||
assert_eq!(report.errors.len(), 1, "{report:?}");
|
||||
assert!(!report.finished_at.is_empty());
|
||||
assert_eq!(mgr.report(&project.id), Some(report));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use super::payload::Payload;
|
||||
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::marketplace::{SkippedItem, SyncReport};
|
||||
|
||||
/// Where the payload and the script are uploaded. Owned by `claude`.
|
||||
@@ -14,10 +14,19 @@ pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incomi
|
||||
/// app version reaches existing containers without an image migration.
|
||||
pub const SYNC_SCRIPT: &str = include_str!("sync.sh");
|
||||
|
||||
/// True once the entrypoint has finished: its last step execs this exact
|
||||
/// command line. Before that it may still be merging `settings.json` or running
|
||||
/// `claude update`, both of which the sync would race.
|
||||
const READY_PROBE: &str = "pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null";
|
||||
/// True once the entrypoint has finished: its last step starts a process with
|
||||
/// one of these exact command lines. Before that it may still be merging
|
||||
/// `settings.json` or running `claude update`, both of which the sync would race.
|
||||
///
|
||||
/// Two, because there are two generations of entrypoint in the field. Current
|
||||
/// images keep the container alive with a root-owned `triple-c-keepalive`
|
||||
/// sleep (so a `pkill sleep` run as `claude` cannot stop the container — see
|
||||
/// the end of `container/entrypoint.sh`); older ones `exec su … claude -c exec
|
||||
/// sleep infinity`, and a project keeps its base image until it is migrated.
|
||||
/// Both are matched as root-owned only (the old image's `su` runs as root), so
|
||||
/// a `claude` process wearing either command line cannot fake readiness.
|
||||
const READY_PROBE: &str = "pgrep -u root -x -f 'triple-c-keepalive infinity' >/dev/null \
|
||||
|| pgrep -u root -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
const READY_POLL: Duration = Duration::from_secs(2);
|
||||
|
||||
@@ -48,10 +57,12 @@ fn run_script_env() -> Vec<String> {
|
||||
vec!["HOME=/home/claude".to_string()]
|
||||
}
|
||||
|
||||
async fn wait_until_ready(container_id: &str) -> Result<(), String> {
|
||||
async fn wait_until_ready(target: &ContainerTarget) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let (_, code) = exec_oneshot_as(container_id, "root", ready_probe_cmd(), vec![]).await?;
|
||||
let (_, code) = target
|
||||
.exec_oneshot_as("root", ready_probe_cmd(), vec![])
|
||||
.await?;
|
||||
if code == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -81,35 +92,31 @@ fn tail(text: &str, max: usize) -> &str {
|
||||
|
||||
/// Wait for readiness, upload the payload and the script, run the script as
|
||||
/// `claude`, and return its report.
|
||||
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
|
||||
wait_until_ready(container_id).await?;
|
||||
pub async fn sync_container(
|
||||
target: &ContainerTarget,
|
||||
payload: &Payload,
|
||||
) -> Result<SyncReport, String> {
|
||||
wait_until_ready(target).await?;
|
||||
|
||||
let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?;
|
||||
let (out, code) = target
|
||||
.exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![])
|
||||
.await?;
|
||||
if code != 0 {
|
||||
return Err(format!(
|
||||
"Could not prepare the container for the marketplace sync: {}",
|
||||
tail(&out, 500)
|
||||
));
|
||||
}
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"payload.tar",
|
||||
&payload.tar,
|
||||
0o644,
|
||||
)
|
||||
.await?;
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"sync.sh",
|
||||
SYNC_SCRIPT.as_bytes(),
|
||||
0o755,
|
||||
)
|
||||
.await?;
|
||||
target
|
||||
.upload_bytes(INCOMING_DIR, "payload.tar", &payload.tar, 0o644)
|
||||
.await?;
|
||||
target
|
||||
.upload_bytes(INCOMING_DIR, "sync.sh", SYNC_SCRIPT.as_bytes(), 0o755)
|
||||
.await?;
|
||||
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?;
|
||||
let (stdout, stderr, code) = target
|
||||
.exec_oneshot_streams_as("claude", run_script_cmd(), run_script_env())
|
||||
.await?;
|
||||
parse_report(&stdout).map_err(|e| {
|
||||
format!(
|
||||
"The marketplace sync script failed (exit {code}): {e}. {}",
|
||||
@@ -196,14 +203,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn readiness_probes_the_entrypoints_final_exec() {
|
||||
// Both generations of entrypoint: a project keeps the base image it
|
||||
// was built from until it migrates, so the old `su … sleep` ending is
|
||||
// the common case for years and must still read as ready.
|
||||
assert_eq!(
|
||||
ready_probe_cmd(),
|
||||
vec![
|
||||
"sh",
|
||||
"-c",
|
||||
"pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"
|
||||
"pgrep -u root -x -f 'triple-c-keepalive infinity' >/dev/null \
|
||||
|| pgrep -u root -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"
|
||||
]
|
||||
);
|
||||
// The marker name is shared with the entrypoint; a rename on one side
|
||||
// only would leave every new container "not ready" for 180 s.
|
||||
let entrypoint = include_str!("../../../../container/entrypoint.sh");
|
||||
assert!(entrypoint.contains("exec -a triple-c-keepalive sleep infinity"));
|
||||
assert_eq!(READY_POLL, Duration::from_secs(2));
|
||||
assert_eq!(READY_TIMEOUT, Duration::from_secs(180));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::gateway_settings::GatewaySettings;
|
||||
use super::host_connection::HostConnection;
|
||||
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
|
||||
use super::project::{ClaudeCodeSettings, EnvVar};
|
||||
|
||||
@@ -143,6 +144,9 @@ pub struct AppSettings {
|
||||
/// Marketplace git repos the user added.
|
||||
#[serde(default)]
|
||||
pub marketplaces: Vec<Marketplace>,
|
||||
/// Remote host connections (AWS). Secrets live in the OS keychain, never here.
|
||||
#[serde(default)]
|
||||
pub hosts: Vec<HostConnection>,
|
||||
/// Items installed for every project (projects may opt out per item).
|
||||
#[serde(default)]
|
||||
pub global_marketplace_installs: Vec<MarketplaceInstall>,
|
||||
@@ -259,6 +263,7 @@ impl Default for AppSettings {
|
||||
global_claude_code_settings: None,
|
||||
marketplace_accounts: Vec::new(),
|
||||
marketplaces: Vec::new(),
|
||||
hosts: Vec::new(),
|
||||
global_marketplace_installs: Vec::new(),
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Remote host connections (currently AWS). Only non-secret data lives here;
|
||||
//! access keys are held in the OS keychain and referenced by `keyring_ref`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: HostKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HostKind {
|
||||
Aws { auth: AwsAuth, default_region: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "method", rename_all = "snake_case")]
|
||||
pub enum AwsAuth {
|
||||
/// A named profile from the host's AWS config.
|
||||
Profile { name: String },
|
||||
/// Static keys stored in the OS keychain under this reference.
|
||||
Keys { keyring_ref: String },
|
||||
}
|
||||
|
||||
/// Unicode format characters (category Cf): invisible, but able to reorder or
|
||||
/// hide text (bidi overrides, zero-width spaces). `char::is_control` is Cc only.
|
||||
fn is_format_char(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F | 0x180E
|
||||
| 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F
|
||||
| 0xFEFF | 0xFFF9..=0xFFFB | 0xE0001 | 0xE0020..=0xE007F)
|
||||
}
|
||||
|
||||
/// Trimmed, 1..=64 characters, no control or invisible format characters.
|
||||
pub fn validate_connection_name(name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
let len = name.chars().count();
|
||||
if len == 0 || len > 64 {
|
||||
return Err("Connection name must be 1 to 64 characters".into());
|
||||
}
|
||||
if name.chars().any(|c| c.is_control() || is_format_char(c)) {
|
||||
return Err("Connection name must not contain control or invisible formatting characters".into());
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// An AWS region id such as `us-west-2` or `us-gov-west-1`.
|
||||
pub fn validate_region(region: &str) -> Result<String, String> {
|
||||
let b = region.as_bytes();
|
||||
let ok = (|| {
|
||||
// ^[a-z]{2}(-[a-z]+)+-\d+$
|
||||
if b.len() < 2 || !b[..2].iter().all(u8::is_ascii_lowercase) {
|
||||
return false;
|
||||
}
|
||||
let mut parts = region[2..].split('-');
|
||||
if parts.next() != Some("") {
|
||||
return false;
|
||||
}
|
||||
let rest: Vec<&str> = parts.collect();
|
||||
let Some((last, words)) = rest.split_last() else {
|
||||
return false;
|
||||
};
|
||||
!words.is_empty()
|
||||
&& words
|
||||
.iter()
|
||||
.all(|w| !w.is_empty() && w.bytes().all(|c| c.is_ascii_lowercase()))
|
||||
&& !last.is_empty()
|
||||
&& last.bytes().all(|c| c.is_ascii_digit())
|
||||
})();
|
||||
if ok {
|
||||
Ok(region.to_string())
|
||||
} else {
|
||||
Err(format!("\"{}\" is not a valid AWS region", region.escape_debug()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A region Triple-C's policies and checks can work with. Every policy and check
|
||||
/// template hard-codes the `aws` partition (`arn:aws:`), so China, GovCloud and
|
||||
/// the isolated partitions would always check as Insufficient. `validate_region`
|
||||
/// stays general (it only checks the shape); connection add/update/import use this.
|
||||
pub fn validate_supported_region(region: &str) -> Result<String, String> {
|
||||
let region = validate_region(region)?;
|
||||
if ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"]
|
||||
.iter()
|
||||
.any(|p| region.starts_with(p))
|
||||
{
|
||||
return Err("China and GovCloud regions aren't supported yet.".into());
|
||||
}
|
||||
Ok(region)
|
||||
}
|
||||
|
||||
/// Non-empty, at most 128 characters of `[A-Za-z0-9_.@+=,-]`.
|
||||
pub fn validate_profile_name(name: &str) -> Result<String, String> {
|
||||
if name.is_empty() || name.len() > 128 {
|
||||
return Err("Profile name must be 1 to 128 characters".into());
|
||||
}
|
||||
if !name
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"_.@+=,-".contains(&c))
|
||||
{
|
||||
return Err("Profile name contains characters AWS profiles do not allow".into());
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::AppSettings;
|
||||
|
||||
#[test]
|
||||
fn settings_without_hosts_deserialize_to_empty() {
|
||||
let mut json = serde_json::to_value(AppSettings::default()).unwrap();
|
||||
json.as_object_mut().unwrap().remove("hosts");
|
||||
let s: AppSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(s.hosts, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_connection_round_trips() {
|
||||
let c = HostConnection {
|
||||
id: "h1".into(),
|
||||
name: "Prod".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile { name: "default".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
};
|
||||
let s = serde_json::to_string(&c).unwrap();
|
||||
assert!(s.contains("\"type\":\"aws\""), "{s}");
|
||||
assert!(s.contains("\"method\":\"profile\""), "{s}");
|
||||
assert_eq!(serde_json::from_str::<HostConnection>(&s).unwrap(), c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regions_are_validated() {
|
||||
for ok in ["us-west-2", "eu-central-1", "cn-north-1", "us-gov-west-1"] {
|
||||
assert_eq!(validate_region(ok).unwrap(), ok);
|
||||
}
|
||||
for ok in ["us-west-2", "eu-central-1", "ap-southeast-4"] {
|
||||
assert_eq!(validate_supported_region(ok).unwrap(), ok);
|
||||
}
|
||||
for other in ["cn-north-1", "cn-northwest-1", "us-gov-west-1", "us-gov-east-1"] {
|
||||
assert_eq!(
|
||||
validate_supported_region(other).unwrap_err(),
|
||||
"China and GovCloud regions aren't supported yet."
|
||||
);
|
||||
}
|
||||
assert!(validate_supported_region("nope").unwrap_err().contains("not a valid"));
|
||||
for bad in ["", "US-WEST-2", "us-west", "us-west-2; rm"] {
|
||||
assert!(validate_region(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_names_are_validated() {
|
||||
for ok in ["default", "340315884407_StaffAdministratorAccess"] {
|
||||
assert_eq!(validate_profile_name(ok).unwrap(), ok);
|
||||
}
|
||||
for bad in ["", "a b", "x\n"] {
|
||||
assert!(validate_profile_name(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(validate_profile_name(&"a".repeat(129)).is_err());
|
||||
assert!(validate_profile_name(&"a".repeat(128)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_names_are_validated() {
|
||||
assert_eq!(validate_connection_name(" Prod ").unwrap(), "Prod");
|
||||
assert!(validate_connection_name(" ").is_err());
|
||||
assert!(validate_connection_name("a\tb").is_err());
|
||||
for c in ['\u{202E}', '\u{200B}', '\u{2066}', '\u{2069}', '\u{FEFF}'] {
|
||||
assert!(validate_connection_name(&format!("ab{c}cd")).is_err(), "{c:?}");
|
||||
}
|
||||
assert!(validate_connection_name("Prod é 日本").is_ok());
|
||||
assert!(validate_connection_name(&"a".repeat(65)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app_settings;
|
||||
pub mod container_config;
|
||||
pub mod gateway_settings;
|
||||
pub mod host_connection;
|
||||
pub mod marketplace;
|
||||
pub mod migration;
|
||||
pub mod note;
|
||||
@@ -11,6 +12,7 @@ pub mod update_info;
|
||||
pub use app_settings::*;
|
||||
pub use container_config::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use host_connection::*;
|
||||
pub use migration::*;
|
||||
pub use note::*;
|
||||
pub use project::*;
|
||||
|
||||
@@ -64,6 +64,11 @@ pub struct ExportedSecrets {
|
||||
/// here or an imported account could never fetch.
|
||||
#[serde(default)]
|
||||
pub marketplace_account_tokens: BTreeMap<String, String>,
|
||||
/// Access keys of `AwsAuth::Keys` host connections, keyed by connection
|
||||
/// id; each value is the `AwsKeys` JSON. Like the tokens above they live
|
||||
/// in the keychain, not in `AppSettings::hosts`.
|
||||
#[serde(default)]
|
||||
pub aws_connection_keys: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExportedSecrets {
|
||||
@@ -74,6 +79,7 @@ impl ExportedSecrets {
|
||||
&& blank(&self.gateway_master_key)
|
||||
&& blank(&self.web_terminal_access_token)
|
||||
&& self.marketplace_account_tokens.values().all(|v| v.trim().is_empty())
|
||||
&& self.aws_connection_keys.values().all(|v| v.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +177,69 @@ pub struct SettingsImportPreview {
|
||||
/// Non-blank marketplace account tokens the import restores.
|
||||
#[serde(default)]
|
||||
pub marketplace_account_token_count: usize,
|
||||
/// The AWS connections the import brings. Applied wholesale, so a crafted
|
||||
/// export could otherwise add or replace one unannounced. Names and
|
||||
/// profile names are cleaned; no key values, only the count below.
|
||||
#[serde(default)]
|
||||
pub aws_connections: Vec<AwsConnectionPreview>,
|
||||
/// Non-blank AWS access-key entries the import restores to the keychain.
|
||||
#[serde(default)]
|
||||
pub aws_connection_key_count: usize,
|
||||
/// Local connections the import drops, because it replaces the whole list.
|
||||
/// Their keychain entries, if any, are left behind.
|
||||
#[serde(default)]
|
||||
pub removed_aws_connections: Vec<AwsConnectionSummary>,
|
||||
}
|
||||
|
||||
/// Cleaned, non-secret description of one connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AwsConnectionSummary {
|
||||
pub name: String,
|
||||
/// `"profile"` or `"keys"`.
|
||||
pub auth: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub default_region: String,
|
||||
}
|
||||
|
||||
impl AwsConnectionSummary {
|
||||
fn of(h: &crate::models::host_connection::HostConnection) -> Self {
|
||||
use crate::models::host_connection::{AwsAuth, HostKind};
|
||||
let HostKind::Aws { auth, default_region } = &h.kind;
|
||||
let (auth_kind, profile_name) = match auth {
|
||||
AwsAuth::Profile { name } => ("profile", Some(sanitize_for_preview(name))),
|
||||
AwsAuth::Keys { .. } => ("keys", None),
|
||||
};
|
||||
Self {
|
||||
name: sanitize_for_preview(&h.name),
|
||||
auth: auth_kind.to_string(),
|
||||
profile_name,
|
||||
default_region: sanitize_for_preview(default_region),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One imported AWS connection as the confirmation dialog shows it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AwsConnectionPreview {
|
||||
pub name: String,
|
||||
/// `"profile"` or `"keys"`.
|
||||
pub auth: String,
|
||||
/// The profile name, for a `profile` connection.
|
||||
pub profile_name: Option<String>,
|
||||
pub default_region: String,
|
||||
/// A connection with this id exists here with different content.
|
||||
pub overwrites_existing: bool,
|
||||
/// The import carries keys for this id and a keychain entry for it
|
||||
/// already exists here (its value is never read into the preview).
|
||||
pub overwrites_keys: bool,
|
||||
/// The import carries keys for this id and the keychain could not be
|
||||
/// asked whether some are stored already.
|
||||
#[serde(default)]
|
||||
pub keys_check_failed: bool,
|
||||
/// The local connection with this id, when the import replaces it, so an
|
||||
/// imported name cannot hide which connection is being replaced.
|
||||
#[serde(default)]
|
||||
pub local: Option<AwsConnectionSummary>,
|
||||
}
|
||||
|
||||
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||
@@ -195,7 +264,55 @@ fn sanitize_for_preview(value: &str) -> String {
|
||||
}
|
||||
|
||||
impl SettingsImportPreview {
|
||||
#[cfg(test)]
|
||||
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||
Self::from_payload_against(payload, &[], |_| Some(false))
|
||||
}
|
||||
|
||||
/// Builds the preview, also flagging what the import would overwrite or
|
||||
/// drop: `local_hosts` are the connections held now and `local_has_keys`
|
||||
/// says whether a keychain entry exists for an id (`None`: could not be
|
||||
/// determined).
|
||||
pub fn from_payload_against(
|
||||
payload: &SettingsExportPayload,
|
||||
local_hosts: &[crate::models::host_connection::HostConnection],
|
||||
local_has_keys: impl Fn(&str) -> Option<bool>,
|
||||
) -> Self {
|
||||
let carried_keys = |id: &str| {
|
||||
payload
|
||||
.secrets
|
||||
.aws_connection_keys
|
||||
.get(id)
|
||||
.is_some_and(|v| !v.trim().is_empty())
|
||||
};
|
||||
let aws_connections = payload
|
||||
.settings
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let summary = AwsConnectionSummary::of(h);
|
||||
let local = local_hosts.iter().find(|l| l.id == h.id);
|
||||
let overwrites_existing = local.is_some_and(|l| l != h);
|
||||
let key_state = if carried_keys(&h.id) { local_has_keys(&h.id) } else { Some(false) };
|
||||
AwsConnectionPreview {
|
||||
name: summary.name,
|
||||
auth: summary.auth,
|
||||
profile_name: summary.profile_name,
|
||||
default_region: summary.default_region,
|
||||
overwrites_existing,
|
||||
overwrites_keys: key_state == Some(true),
|
||||
keys_check_failed: key_state.is_none(),
|
||||
local: local
|
||||
.filter(|_| overwrites_existing || key_state != Some(false))
|
||||
.map(AwsConnectionSummary::of),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let removed_aws_connections = local_hosts
|
||||
.iter()
|
||||
.filter(|l| !payload.settings.hosts.iter().any(|h| h.id == l.id))
|
||||
.map(AwsConnectionSummary::of)
|
||||
.collect();
|
||||
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
let sanitized_non_blank = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
@@ -240,6 +357,14 @@ impl SettingsImportPreview {
|
||||
.values()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.count(),
|
||||
aws_connections,
|
||||
aws_connection_key_count: payload
|
||||
.secrets
|
||||
.aws_connection_keys
|
||||
.values()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.count(),
|
||||
removed_aws_connections,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,4 +602,98 @@ mod tests {
|
||||
assert_eq!(preview.global_plugin_install_count, 2);
|
||||
assert_eq!(preview.global_hook_install_count, 1);
|
||||
}
|
||||
|
||||
fn aws_host(id: &str, name: &str, keys: bool) -> crate::models::host_connection::HostConnection {
|
||||
use crate::models::host_connection::*;
|
||||
HostConnection {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: if keys {
|
||||
AwsAuth::Keys { keyring_ref: id.into() }
|
||||
} else {
|
||||
AwsAuth::Profile { name: "prod-profile".into() }
|
||||
},
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preview_discloses_aws_connections_and_key_count() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([
|
||||
("k1".to_string(), "{\"secret_access_key\":\"test-secret-not-real\"}".to_string()),
|
||||
("k2".to_string(), " ".to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![aws_host("k1", "Ke\nys", true), aws_host("p1", "Prof", false)];
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.aws_connection_key_count, 1);
|
||||
assert_eq!(preview.aws_connections.len(), 2);
|
||||
assert_eq!(preview.aws_connections[0].name, "Keys");
|
||||
assert_eq!(preview.aws_connections[0].auth, "keys");
|
||||
assert_eq!(preview.aws_connections[1].auth, "profile");
|
||||
assert_eq!(preview.aws_connections[1].profile_name.as_deref(), Some("prod-profile"));
|
||||
assert_eq!(preview.aws_connections[1].default_region, "us-west-2");
|
||||
assert!(!serde_json::to_string(&preview).unwrap().contains("test-secret-not-real"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_preview_flags_aws_overwrites() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([("k1".to_string(), "{}".to_string())]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![
|
||||
aws_host("k1", "Keys", true),
|
||||
aws_host("same", "Same", false),
|
||||
aws_host("changed", "New name", false),
|
||||
aws_host("fresh", "Fresh", false),
|
||||
];
|
||||
let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)];
|
||||
let preview =
|
||||
SettingsImportPreview::from_payload_against(&payload, &local, |id| Some(id == "k1"));
|
||||
let flags: Vec<_> = preview
|
||||
.aws_connections
|
||||
.iter()
|
||||
.map(|c| (c.overwrites_existing, c.overwrites_keys))
|
||||
.collect();
|
||||
assert_eq!(flags, vec![(false, true), (false, false), (true, false), (false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_preview_names_the_local_connection_and_discloses_removals_and_failed_checks() {
|
||||
let mut payload = payload_with(ExportedSecrets {
|
||||
aws_connection_keys: BTreeMap::from([
|
||||
("k1".to_string(), "{}".to_string()),
|
||||
("k2".to_string(), "{}".to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
payload.settings.hosts = vec![
|
||||
aws_host("k1", "Innocent", true),
|
||||
aws_host("k2", "Other", true),
|
||||
aws_host("p1", "Fresh Name", false),
|
||||
];
|
||||
let local = vec![
|
||||
aws_host("k1", "Real Prod", true),
|
||||
aws_host("p1", "Real Dev", false),
|
||||
aws_host("gone", "Dropped\n", false),
|
||||
];
|
||||
let preview = SettingsImportPreview::from_payload_against(&payload, &local, |id| match id {
|
||||
"k1" => Some(true),
|
||||
_ => None,
|
||||
});
|
||||
let c = &preview.aws_connections;
|
||||
assert!(c[0].overwrites_keys && !c[0].keys_check_failed);
|
||||
assert_eq!(c[0].local.as_ref().unwrap().name, "Real Prod");
|
||||
assert!(c[1].keys_check_failed && !c[1].overwrites_keys && c[1].local.is_none());
|
||||
assert!(c[2].overwrites_existing);
|
||||
assert_eq!(c[2].local.as_ref().unwrap().name, "Real Dev");
|
||||
assert_eq!(c[2].local.as_ref().unwrap().profile_name.as_deref(), Some("prod-profile"));
|
||||
assert_eq!(preview.removed_aws_connections.len(), 1);
|
||||
assert_eq!(preview.removed_aws_connections[0].name, "Dropped");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,42 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
|
||||
use crate::models::Project;
|
||||
|
||||
/// Proof that, at some revision of the store, a project was `Running` on a
|
||||
/// given container. See [`ProjectsStore::running_on`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RunningSighting {
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// What [`ProjectsStore::mark_exited`] did.
|
||||
#[derive(Debug)]
|
||||
pub enum ExitMark {
|
||||
/// Flipped to `Stopped`; the updated record.
|
||||
Flipped(Box<Project>),
|
||||
/// The store changed since the sighting while a project is still Running
|
||||
/// on that container — the caller's Docker check may be stale. Re-check.
|
||||
Changed,
|
||||
/// No project is Running on that container, or a command owns it.
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
/// The one rule for "this project is Running on that container". Both ids
|
||||
/// must be at least a short id long: a blank or truncated value is a prefix
|
||||
/// of everything and would match every container on the daemon.
|
||||
fn is_running_on(p: &Project, container_id: &str) -> bool {
|
||||
p.status == crate::models::ProjectStatus::Running
|
||||
&& container_id.len() >= 12
|
||||
&& p.container_id
|
||||
.as_deref()
|
||||
.filter(|stored| stored.len() >= 12)
|
||||
.is_some_and(|stored| stored.starts_with(container_id) || container_id.starts_with(stored))
|
||||
}
|
||||
|
||||
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
|
||||
///
|
||||
/// Derived from the file rather than from `dirs::data_dir()` so the marker
|
||||
@@ -64,6 +96,8 @@ fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||
pub struct ProjectsStore {
|
||||
projects: Mutex<Vec<Project>>,
|
||||
file_path: PathBuf,
|
||||
/// Bumped by every `save()`; see [`ProjectsStore::mark_exited`].
|
||||
revision: AtomicU64,
|
||||
}
|
||||
|
||||
impl ProjectsStore {
|
||||
@@ -147,6 +181,7 @@ impl ProjectsStore {
|
||||
let store = Self {
|
||||
projects: Mutex::new(projects),
|
||||
file_path,
|
||||
revision: AtomicU64::new(0),
|
||||
};
|
||||
|
||||
// Persist migrated/reconciled format back to disk
|
||||
@@ -161,11 +196,21 @@ impl ProjectsStore {
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// A store over a file the test owns (`new()` insists on the real data dir).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn over_file_for_test(file_path: PathBuf, projects: Vec<Project>) -> Self {
|
||||
Self { projects: Mutex::new(projects), file_path, revision: AtomicU64::new(0) }
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
||||
self.projects.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn save(&self, projects: &[Project]) -> Result<(), String> {
|
||||
// Every mutation saves with the lock held, so this counts every change
|
||||
// to the in-memory list — bumped first, because a failed write has
|
||||
// still changed what the list holds.
|
||||
self.revision.fetch_add(1, Ordering::SeqCst);
|
||||
let data = serde_json::to_string_pretty(projects)
|
||||
.map_err(|e| format!("Failed to serialize projects: {}", e))?;
|
||||
|
||||
@@ -237,6 +282,69 @@ impl ProjectsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is some project `Running` on `container_id`? Checked under the lock
|
||||
/// without cloning anything — the container watcher asks this for every
|
||||
/// container death on the daemon, nearly all of them not ours.
|
||||
///
|
||||
/// The answer carries the store's revision, which [`Self::mark_exited`]
|
||||
/// needs: the caller confirms with Docker that the container really is
|
||||
/// down *between* the two calls, and that inspect can be slow.
|
||||
pub fn running_on(&self, container_id: &str) -> Option<RunningSighting> {
|
||||
let projects = self.lock();
|
||||
projects
|
||||
.iter()
|
||||
.any(|p| is_running_on(p, container_id))
|
||||
.then(|| RunningSighting { revision: self.revision.load(Ordering::SeqCst) })
|
||||
}
|
||||
|
||||
/// A container stopped without the app asking: flip the project that is
|
||||
/// `Running` on it to `Stopped`.
|
||||
///
|
||||
/// A compare-and-set under the store lock rather than a read followed by
|
||||
/// `update_status`, and against two things:
|
||||
///
|
||||
/// * **The store's revision as of `seen`.** Between [`Self::running_on`]
|
||||
/// and here the caller waited on a Docker inspect. If the user stopped
|
||||
/// and restarted the project meanwhile — same container id, stop/start
|
||||
/// model — that inspect's "not running" is about a container that has
|
||||
/// since come back, and flipping would mark a healthy project Stopped.
|
||||
/// Any write to the store in that window bumps the revision, and the
|
||||
/// answer is [`ExitMark::Changed`]: confirm again and retry.
|
||||
/// * **`is_busy`** — the project lock. Stop, Start, Reset and migration
|
||||
/// hold it across their own container's death and write the status they
|
||||
/// mean themselves. A start that takes the project lock just after this
|
||||
/// check still blocks on the store lock before it writes `Starting`.
|
||||
///
|
||||
/// `container_id` may be the short form; the store holds the full id.
|
||||
pub fn mark_exited(
|
||||
&self,
|
||||
container_id: &str,
|
||||
seen: &RunningSighting,
|
||||
is_busy: impl Fn(&str) -> bool,
|
||||
) -> ExitMark {
|
||||
let mut projects = self.lock();
|
||||
let unchanged = self.revision.load(Ordering::SeqCst) == seen.revision;
|
||||
let Some(p) = projects.iter_mut().find(|p| is_running_on(p, container_id)) else {
|
||||
return ExitMark::NotApplicable;
|
||||
};
|
||||
if !unchanged {
|
||||
return ExitMark::Changed;
|
||||
}
|
||||
if is_busy(&p.id) {
|
||||
return ExitMark::NotApplicable;
|
||||
}
|
||||
p.status = crate::models::ProjectStatus::Stopped;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
let changed = p.clone();
|
||||
if let Err(e) = self.save(&projects) {
|
||||
// The in-memory record is what the UI is told about and what every
|
||||
// later command reads, so it stays flipped; the next save of any
|
||||
// kind writes it out.
|
||||
log::warn!("Could not persist Stopped for project {}: {}", changed.id, e);
|
||||
}
|
||||
ExitMark::Flipped(Box::new(changed))
|
||||
}
|
||||
|
||||
pub fn update_status(&self, id: &str, status: crate::models::ProjectStatus) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == id) {
|
||||
@@ -437,6 +545,7 @@ mod tests {
|
||||
ProjectsStore {
|
||||
projects: Mutex::new(projects),
|
||||
file_path: dir.join("projects.json"),
|
||||
revision: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,4 +713,138 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── running_on / mark_exited: a container that died on its own ───────
|
||||
|
||||
/// A full-length id, as Docker's events and the store both carry.
|
||||
const C0FFEE: &str = "c0ffee0000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
fn running_on(container_id: &str) -> Project {
|
||||
let mut p = Project::new("p".to_string(), Vec::new());
|
||||
p.container_id = Some(container_id.to_string());
|
||||
p.status = crate::models::ProjectStatus::Running;
|
||||
p
|
||||
}
|
||||
|
||||
/// Sighting and flip back to back, as when Docker answers instantly.
|
||||
fn sight_and_mark(store: &ProjectsStore, cid: &str, busy: impl Fn(&str) -> bool) -> Option<Project> {
|
||||
let seen = store.running_on(cid)?;
|
||||
match store.mark_exited(cid, &seen, busy) {
|
||||
ExitMark::Flipped(p) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_project_whose_container_exited_is_marked_stopped_and_saved() {
|
||||
let dir = temp_dir("exited");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
let changed = sight_and_mark(&store, C0FFEE, |_| false).expect("flipped");
|
||||
|
||||
assert_eq!(changed.id, id);
|
||||
assert_eq!(changed.status, crate::models::ProjectStatus::Stopped);
|
||||
assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Stopped);
|
||||
let on_disk = fs::read_to_string(dir.join("projects.json")).unwrap();
|
||||
assert!(on_disk.contains("\"stopped\""), "the flip must be persisted: {on_disk}");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_exited_leaves_a_project_alone_while_a_command_owns_it() {
|
||||
// Stop, Start-with-recreate, Reset and migration all hold the project
|
||||
// lock across their own container's death; the command writes the
|
||||
// status it means, and the watcher must not race it.
|
||||
let dir = temp_dir("busy");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
assert!(sight_and_mark(&store, C0FFEE, |pid| pid == id).is_none());
|
||||
assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Running);
|
||||
// ...and it was only the claim that held it back.
|
||||
assert!(sight_and_mark(&store, C0FFEE, |_| false).is_some());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The slow-inspect race. The watcher sights the project Running, then
|
||||
/// waits on Docker; meanwhile the user stops and starts it again on the
|
||||
/// *same* container id (stop/start model). Docker's "not running" is now
|
||||
/// about a container that has come back, so the flip must not land.
|
||||
#[test]
|
||||
fn a_stop_and_start_during_the_docker_check_invalidates_the_sighting() {
|
||||
let dir = temp_dir("restarted");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
let seen = store.running_on(C0FFEE).expect("sighted");
|
||||
// ...the inspect is in flight...
|
||||
store.update_status(&id, crate::models::ProjectStatus::Stopping).unwrap();
|
||||
store.update_status(&id, crate::models::ProjectStatus::Stopped).unwrap();
|
||||
store.update_status(&id, crate::models::ProjectStatus::Starting).unwrap();
|
||||
store.update_status(&id, crate::models::ProjectStatus::Running).unwrap();
|
||||
|
||||
assert!(matches!(store.mark_exited(C0FFEE, &seen, |_| false), ExitMark::Changed));
|
||||
assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Running);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_sighting_of_a_project_no_longer_running_is_simply_not_applicable() {
|
||||
let dir = temp_dir("gone");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
let seen = store.running_on(C0FFEE).expect("sighted");
|
||||
store.update_status(&id, crate::models::ProjectStatus::Stopped).unwrap();
|
||||
|
||||
assert!(matches!(store.mark_exited(C0FFEE, &seen, |_| false), ExitMark::NotApplicable));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_running_projects_on_that_container_are_sighted_or_touched() {
|
||||
let dir = temp_dir("other");
|
||||
let mut starting = running_on(C0FFEE);
|
||||
starting.status = crate::models::ProjectStatus::Starting;
|
||||
let other = running_on(&"b".repeat(64));
|
||||
let (starting_id, other_id) = (starting.id.clone(), other.id.clone());
|
||||
let store = store_over(&dir, vec![starting, other]);
|
||||
|
||||
assert!(store.running_on(C0FFEE).is_none(), "Starting is a command's");
|
||||
assert!(store.running_on(&"d".repeat(64)).is_none(), "nobody's container");
|
||||
assert!(store.running_on("").is_none());
|
||||
assert!(sight_and_mark(&store, C0FFEE, |_| false).is_none());
|
||||
assert_eq!(
|
||||
store.get(&starting_id).unwrap().status,
|
||||
crate::models::ProjectStatus::Starting
|
||||
);
|
||||
assert_eq!(store.get(&other_id).unwrap().status, crate::models::ProjectStatus::Running);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_or_truncated_stored_id_matches_no_container() {
|
||||
// A blank stored id is a prefix of everything.
|
||||
let dir = temp_dir("blank");
|
||||
let store = store_over(&dir, vec![running_on(""), running_on("abc")]);
|
||||
assert!(store.running_on(&"f".repeat(64)).is_none());
|
||||
assert!(store.running_on("abcdef0123456789").is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_id_is_matched_against_the_stored_full_one() {
|
||||
let dir = temp_dir("short");
|
||||
let full = "a".repeat(12) + &"b".repeat(52);
|
||||
let store = store_over(&dir, vec![running_on(&full)]);
|
||||
|
||||
assert!(store.running_on("aaaa").is_none(), "too short to be an id");
|
||||
assert!(sight_and_mark(&store, &full[..12], |_| false).is_some());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,12 @@ fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
|
||||
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.delete_credential() {
|
||||
delete_outcome(entry.delete_credential(), label)
|
||||
}
|
||||
|
||||
/// "Wasn't there" is success; any other failure names the entry, not its value.
|
||||
fn delete_outcome(result: Result<(), keyring::Error>, label: &str) -> Result<(), String> {
|
||||
match result {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
|
||||
}
|
||||
@@ -379,10 +384,7 @@ const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
|
||||
/// The service name for one account. Ids are uuids; anything else is refused
|
||||
/// before a keychain entry is constructed.
|
||||
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
|
||||
let ok = !account_id.is_empty()
|
||||
&& account_id.len() <= 64
|
||||
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
|
||||
if !ok {
|
||||
if !is_valid_connection_id(account_id) {
|
||||
return Err(format!("Invalid marketplace account id {:?}", account_id));
|
||||
}
|
||||
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
|
||||
@@ -408,6 +410,90 @@ pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
|
||||
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AWS connection access keys (global, one entry per connection)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Keychain service prefix; the connection id completes it.
|
||||
const AWS_CONNECTION_SERVICE_PREFIX: &str = "triple-c-aws-connection-";
|
||||
|
||||
/// Static AWS credentials for a `HostConnection` using `AwsAuth::Keys`. Held in
|
||||
/// the keychain as one JSON value. `Debug` redacts the secret and the token.
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct AwsKeys {
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub session_token: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AwsKeys {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AwsKeys")
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &"<redacted>")
|
||||
.field("session_token", &self.session_token.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` may be used as a connection id / keyring reference: 1..=64 of
|
||||
/// `[A-Za-z0-9-]`. The single definition of the keychain-id rule: marketplace
|
||||
/// account ids, AWS connection ids and `commands::marketplace_commands`'s
|
||||
/// `validate_id` all go through it.
|
||||
pub fn is_valid_connection_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
}
|
||||
|
||||
fn aws_connection_service(connection_id: &str) -> Result<String, String> {
|
||||
if !is_valid_connection_id(connection_id) {
|
||||
return Err(format!("Invalid AWS connection id {:?}", connection_id));
|
||||
}
|
||||
Ok(format!("{}{}", AWS_CONNECTION_SERVICE_PREFIX, connection_id))
|
||||
}
|
||||
|
||||
pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> {
|
||||
let service = aws_connection_service(connection_id)?;
|
||||
let json = serde_json::to_string(keys)
|
||||
.map_err(|_| "Failed to encode the AWS access keys".to_string())?;
|
||||
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|e| format!("Failed to store the AWS access keys: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_aws_keys(connection_id: &str) -> Result<Option<AwsKeys>, String> {
|
||||
let Some(json) = read_entry(&aws_connection_service(connection_id)?, "the AWS access keys")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
serde_json::from_str(&json)
|
||||
.map(Some)
|
||||
.map_err(|_| "The stored AWS access keys are unreadable".to_string())
|
||||
}
|
||||
|
||||
/// Whether an entry exists for the connection, readable or not. `None` means
|
||||
/// the keychain could not be asked, so callers must not read it as "absent".
|
||||
/// Never returns or logs a value.
|
||||
pub fn aws_keys_entry_state(connection_id: &str) -> Option<bool> {
|
||||
let svc = aws_connection_service(connection_id).ok()?;
|
||||
let entry = keyring::Entry::new(&svc, KEYCHAIN_ACCOUNT).ok()?;
|
||||
match entry.get_password() {
|
||||
Ok(_) => Some(true),
|
||||
Err(keyring::Error::NoEntry) => Some(false),
|
||||
// Present but not a plain string, etc.: something is there.
|
||||
Err(keyring::Error::BadEncoding(_)) => Some(true),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing entry is success.
|
||||
pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> {
|
||||
delete_entry(&aws_connection_service(connection_id)?, "the AWS access keys")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -496,4 +582,39 @@ mod tests {
|
||||
// Pasted credentials routinely carry a trailing newline.
|
||||
assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_keys_debug_redacts_secrets() {
|
||||
let keys = AwsKeys {
|
||||
access_key_id: "AKIATESTNOTREAL".into(),
|
||||
secret_access_key: "test-secret-not-real".into(),
|
||||
session_token: Some("test-session-token-not-real".into()),
|
||||
};
|
||||
let shown = format!("{:?}", keys);
|
||||
assert!(!shown.contains("test-secret-not-real"), "{shown}");
|
||||
assert!(!shown.contains("test-session-token-not-real"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_key_service_names_reject_bad_ids() {
|
||||
for bad in ["../x", "", &"a".repeat(65)] {
|
||||
assert!(aws_connection_service(bad).is_err(), "{bad:?}");
|
||||
assert!(get_aws_keys(bad).is_err());
|
||||
assert!(delete_aws_keys(bad).is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
aws_connection_service("0b9e6a2c-1111").unwrap(),
|
||||
"triple-c-aws-connection-0b9e6a2c-1111"
|
||||
);
|
||||
}
|
||||
|
||||
/// There is no mock keyring here, so the "no entry is fine" rule is tested
|
||||
/// as the pure mapping `delete_entry` applies.
|
||||
#[test]
|
||||
fn removing_a_keys_connection_with_no_keyring_entry_succeeds() {
|
||||
assert_eq!(delete_outcome(Err(keyring::Error::NoEntry), "x"), Ok(()));
|
||||
assert_eq!(delete_outcome(Ok(()), "x"), Ok(()));
|
||||
let err = delete_outcome(Err(keyring::Error::NoStorageAccess("denied".into())), "x");
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,12 @@ impl SettingsStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// A store over an explicit file, for tests (no data-dir lookup).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn at_path(file_path: PathBuf) -> Self {
|
||||
Self { settings: Mutex::new(AppSettings::default()), file_path }
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
|
||||
self.settings.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
@@ -69,8 +75,77 @@ impl SettingsStore {
|
||||
|
||||
pub fn update(&self, new_settings: AppSettings) -> Result<AppSettings, String> {
|
||||
let mut settings = self.lock();
|
||||
// Save first: a failed save must not leave the unsaved copy in memory.
|
||||
self.save(&new_settings)?;
|
||||
*settings = new_settings.clone();
|
||||
self.save(&settings)?;
|
||||
Ok(new_settings)
|
||||
}
|
||||
|
||||
/// Read-modify-save under the store's lock, so concurrent callers cannot
|
||||
/// lose each other's updates. `f` edits a copy; if it fails, or the save
|
||||
/// fails, memory and disk are unchanged. Returns the saved settings and
|
||||
/// whatever `f` returned.
|
||||
pub fn modify<R, F>(&self, f: F) -> Result<(AppSettings, R), String>
|
||||
where
|
||||
F: FnOnce(&mut AppSettings) -> Result<R, String>,
|
||||
{
|
||||
let mut settings = self.lock();
|
||||
let mut copy = settings.clone();
|
||||
let out = f(&mut copy)?;
|
||||
self.save(©)?;
|
||||
*settings = copy.clone();
|
||||
Ok((copy, out))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modify_saves_and_returns_the_closure_result() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::at_path(dir.path().join("settings.json"));
|
||||
let (saved, n) = store
|
||||
.modify(|s| {
|
||||
s.hosts.clear();
|
||||
Ok(7)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(n, 7);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&saved).unwrap(),
|
||||
serde_json::to_value(store.get()).unwrap()
|
||||
);
|
||||
assert!(dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_does_not_change_memory_when_save_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::at_path(dir.path().join("missing/settings.json"));
|
||||
let before = serde_json::to_value(store.get()).unwrap();
|
||||
let err = store.modify(|s| {
|
||||
s.hosts.push(crate::models::HostConnection {
|
||||
id: "x".into(),
|
||||
name: "x".into(),
|
||||
kind: crate::models::HostKind::Aws {
|
||||
auth: crate::models::AwsAuth::Profile { name: "p".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
assert!(err.is_err());
|
||||
assert_eq!(serde_json::to_value(store.get()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_does_not_save_when_the_closure_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::at_path(dir.path().join("settings.json"));
|
||||
let err = store.modify(|_| -> Result<(), String> { Err("no".into()) }).unwrap_err();
|
||||
assert_eq!(err, "no");
|
||||
assert!(!dir.path().join("settings.json").exists());
|
||||
}
|
||||
}
|
||||
@@ -187,11 +187,10 @@ fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
|
||||
/// from the process, so the rules can be tested without a global mutex around
|
||||
/// the environment. Returns changes sorted by variable name so assertions are
|
||||
/// deterministic.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn sanitize_child_env(
|
||||
///
|
||||
/// Also used by the OpenTofu runner (`aws::tofu::tofu_env`), which undoes an
|
||||
/// AppImage launcher's changes before starting `tofu`.
|
||||
pub(crate) fn sanitize_child_env(
|
||||
current: &BTreeMap<String, String>,
|
||||
pristine: &BTreeMap<String, String>,
|
||||
appdir: Option<&str>,
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::docker::exec::ExecSessionManager;
|
||||
use crate::host::ExecSessionManager;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
use crate::storage::settings_store::SettingsStore;
|
||||
|
||||
|
||||
@@ -295,9 +295,7 @@ async fn handle_open(
|
||||
return Err(format!("Project '{}' is not running", project.name));
|
||||
}
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let container = crate::host::ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = match session_type {
|
||||
@@ -329,7 +327,13 @@ async fn handle_open(
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(container_id, &session_id, cmd, on_output, on_exit)
|
||||
.create_session(
|
||||
&container,
|
||||
&session_id,
|
||||
cmd,
|
||||
on_output,
|
||||
on_exit,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Track this session for cleanup on disconnect
|
||||
|
||||
@@ -35,7 +35,12 @@
|
||||
"icons/icon.ico",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png"
|
||||
]
|
||||
],
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useSTT } from "./hooks/useSTT";
|
||||
import { useContainerProgress } from "./hooks/useContainerProgress";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { useMarketplaceSyncToasts } from "./hooks/useMarketplace";
|
||||
import { useProjectStatusEvents } from "./hooks/useProjectStatusEvents";
|
||||
import MarketplaceView from "./components/marketplace/MarketplaceView";
|
||||
import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState";
|
||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||
@@ -75,6 +76,7 @@ export default function App() {
|
||||
useContainerProgress();
|
||||
useKeyboardShortcuts();
|
||||
useMarketplaceSyncToasts();
|
||||
useProjectStatusEvents();
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import HostsSettings from "./HostsSettings";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, HostConnection, PermissionReport } from "../../lib/types";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
|
||||
}));
|
||||
|
||||
const conn: HostConnection = {
|
||||
id: "h1",
|
||||
name: "Prod",
|
||||
kind: { type: "aws", auth: { method: "profile", name: "prod-sso" }, default_region: "eu-west-1" },
|
||||
};
|
||||
const keysConn: HostConnection = {
|
||||
id: "h2",
|
||||
name: "Keys box",
|
||||
kind: { type: "aws", auth: { method: "keys", keyring_ref: "r" }, default_region: "us-east-1" },
|
||||
};
|
||||
const identity = { account: "123456789012", arn: "arn:aws:iam::123456789012:user/me", user_id: "U" };
|
||||
|
||||
function report(over: Partial<PermissionReport>): PermissionReport {
|
||||
return {
|
||||
identity,
|
||||
region: "eu-west-1",
|
||||
method: "simulated",
|
||||
summary: "insufficient",
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "denied", note: null }] },
|
||||
{ tier: "environment_admin", results: [{ action: "iam:CreateRole", verdict: "denied", note: null }] },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setHosts(hosts: HostConnection[]) {
|
||||
useAppState.setState({ appSettings: { hosts } as unknown as AppSettings });
|
||||
}
|
||||
|
||||
describe("HostsSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setHosts([conn, keysConn]);
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
|
||||
if (cmd === "get_settings") return { hosts: [conn, keysConn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Local Docker and existing AWS connections", async () => {
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
expect(screen.getByText("Local Docker")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prod")).toBeInTheDocument();
|
||||
expect(screen.getByText("Profile: prod-sso")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("host-h2")).getByText("Access keys")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("host-h1")).getByText("eu-west-1")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^Check/ })).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: "Remove Local Docker" })).toBeNull();
|
||||
});
|
||||
|
||||
it("adds a profile connection and shows the signed-in identity", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
|
||||
if (cmd === "add_aws_profile_connection") return conn;
|
||||
if (cmd === "aws_connection_identity") return identity;
|
||||
if (cmd === "get_settings") return { hosts: [conn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Default region"), { target: { value: "eu-west-1" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("add_aws_profile_connection", {
|
||||
name: "Dev",
|
||||
profile: "dev",
|
||||
defaultRegion: "eu-west-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers Sign in when the profile's SSO session expired", async () => {
|
||||
let identityCalls = 0;
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev"];
|
||||
if (cmd === "add_aws_profile_connection") return conn;
|
||||
if (cmd === "aws_connection_identity") {
|
||||
if (identityCalls++ === 0) throw { kind: "sso_expired", profile: "dev" };
|
||||
return identity;
|
||||
}
|
||||
if (cmd === "aws_connection_sso_login") return undefined;
|
||||
if (cmd === "get_settings") return { hosts: [conn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("aws_connection_sso_login", { id: "h1" });
|
||||
// The connection was added once; only the failed step was retried.
|
||||
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("access key inputs are password fields and the session token is optional", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "add_aws_keys_connection") return keysConn;
|
||||
if (cmd === "aws_connection_identity") return identity;
|
||||
if (cmd === "get_settings") return { hosts: [keysConn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
|
||||
const id = screen.getByLabelText("Access key ID");
|
||||
const secret = screen.getByLabelText("Secret access key");
|
||||
const token = screen.getByLabelText(/Session token/);
|
||||
for (const el of [id, secret, token]) {
|
||||
expect(el).toHaveAttribute("type", "password");
|
||||
expect(el).toHaveAttribute("autocomplete", "off");
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "K" } });
|
||||
fireEvent.change(id, { target: { value: "AKIA" } });
|
||||
fireEvent.change(secret, { target: { value: "shh" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
await screen.findByText(`Signed in as ${identity.arn}`);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"add_aws_keys_connection",
|
||||
expect.objectContaining({ accessKeyId: "AKIA", secretAccessKey: "shh", sessionToken: null }),
|
||||
);
|
||||
expect(screen.getByLabelText("Access key ID")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Secret access key")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("check shows Insufficient with the denied actions and every policy file to copy", async () => {
|
||||
const adminFiles = [
|
||||
{ name: "environment-admin.json", json: '{"Statement":"admin"}' },
|
||||
{ name: "environment-admin-network.json", json: '{"Statement":"network"}' },
|
||||
];
|
||||
invoke.mockImplementation(async (cmd: string, args?: { tier: string }) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
if (cmd === "aws_policy_document")
|
||||
return args?.tier === "environment_admin"
|
||||
? adminFiles
|
||||
: [{ name: "workspace-user.json", json: '{"Version":"2012-10-17"}' }];
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText("Insufficient")).toBeInTheDocument();
|
||||
expect(screen.getByText("ecs:RunTask")).toBeInTheDocument();
|
||||
expect(screen.getByText(/SCPs or session policies/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show workspace-user policy" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Copy workspace-user.json" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"Version":"2012-10-17"}'));
|
||||
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "workspace_user" });
|
||||
expect(screen.getByText("workspace-user.json copied to clipboard.")).toBeInTheDocument();
|
||||
|
||||
// The admin tier is every one of its files, each shown and copied on its own.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show admin policies" }));
|
||||
expect(await screen.findByText(/Attach all 2 files/)).toBeInTheDocument();
|
||||
for (const f of adminFiles) {
|
||||
expect(screen.getByText(f.json)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: `Copy ${f.name}` }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(f.json));
|
||||
}
|
||||
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "environment_admin" });
|
||||
});
|
||||
|
||||
it("shows a neutral Incomplete badge when the only blockers are failed checks", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "throttled" }] },
|
||||
{ tier: "environment_admin", results: [] },
|
||||
],
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
const badge = await screen.findByText("Incomplete — check again");
|
||||
expect(badge.className).not.toContain("--error");
|
||||
expect(screen.queryByText("Insufficient")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Some checks couldn't complete/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains denied own-principal checks as the first thing to ask an admin for", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "iam:SimulatePrincipalPolicy", verdict: "denied", note: null }] },
|
||||
{ tier: "environment_admin", results: [] },
|
||||
],
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText(/couldn't inspect the caller's own permissions/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows throttling notes with a check-again hint", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
tiers: [
|
||||
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "Throttling" }] },
|
||||
{ tier: "environment_admin", results: [] },
|
||||
],
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText(/Throttling/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Check again/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a probed check says writes are unverified", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions")
|
||||
return report({
|
||||
method: { probed: { reason: "iam:SimulatePrincipalPolicy denied" } },
|
||||
summary: "workspace_user",
|
||||
});
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText("Workspace user (unverified)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Simulation not permitted (iam:SimulatePrincipalPolicy denied). Read access was probed; write actions are unverified and will be confirmed on first use.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks for confirmation before removing", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "remove_host_connection") return { hosts: [keysConn] };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
|
||||
expect(invoke).not.toHaveBeenCalledWith("remove_host_connection", expect.anything());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm remove Prod" }));
|
||||
await waitFor(() => expect(screen.queryByText("Prod")).toBeNull());
|
||||
expect(invoke).toHaveBeenCalledWith("remove_host_connection", { id: "h1" });
|
||||
});
|
||||
|
||||
it("never renders a no-op Sign in when the add itself rejects with sso_expired", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev"];
|
||||
if (cmd === "add_aws_profile_connection") throw { kind: "sso_expired", profile: "dev" };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull();
|
||||
expect(screen.getByText("aws sso login --profile dev")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never re-runs the add when the settings refresh fails after a successful add", async () => {
|
||||
let settingsCalls = 0;
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return ["dev"];
|
||||
if (cmd === "add_aws_profile_connection") return conn;
|
||||
if (cmd === "get_settings") {
|
||||
if (settingsCalls++ === 0) throw "boom";
|
||||
return { hosts: [conn] };
|
||||
}
|
||||
if (cmd === "aws_connection_identity") return identity;
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
await screen.findByRole("option", { name: "dev" });
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
|
||||
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
|
||||
expect(await screen.findByText(/could not be refreshed/)).toBeInTheDocument();
|
||||
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows a message when the profile list cannot be read", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") throw "no config";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
expect(await screen.findByText(/Couldn't read your AWS profiles: no config/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
|
||||
expect(screen.getByLabelText("Access key ID")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a message when copying the policy fails", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
if (cmd === "aws_policy_document") return [{ name: "environment-admin.json", json: "{}" }];
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } });
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Show admin policies" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Copy environment-admin.json" }));
|
||||
expect(await screen.findByText(/Could not copy environment-admin.json: denied/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a message when the policy files can't be loaded", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
throw new Error("boom");
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Show admin policies" }));
|
||||
expect(await screen.findByText(/Could not load the policy: boom/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ kind: "profile_not_found", profile: "ghost" }, "Profile ghost was not found in your AWS config, or has no credentials."],
|
||||
[
|
||||
{ kind: "keys_missing" },
|
||||
"The access keys for this connection are missing from the keychain. Remove and re-add the connection.",
|
||||
],
|
||||
])("a failed Check shows its error: %j", async (err, text) => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") throw err;
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
expect(await screen.findByText(text)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("permission-report")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves focus to Confirm when removing, and back to Remove on cancel", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
|
||||
expect(screen.getByRole("button", { name: "Confirm remove Prod" })).toHaveFocus();
|
||||
fireEvent.click(within(screen.getByTestId("host-h1")).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Remove Prod" })).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import {
|
||||
addAwsKeysConnection,
|
||||
addAwsProfileConnection,
|
||||
awsConnectionIdentity,
|
||||
awsConnectionSsoLogin,
|
||||
checkAwsPermissions,
|
||||
listAwsProfiles,
|
||||
removeHostConnection,
|
||||
updateAwsConnection,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { awsErrorMessage, toAwsError } from "../../lib/aws-error";
|
||||
import PolicyFiles from "./hosts/PolicyFiles";
|
||||
import EnvironmentsPanel from "./hosts/EnvironmentsPanel";
|
||||
import { COMMON_REGIONS } from "./hosts/environmentFormat";
|
||||
import type {
|
||||
AwsError,
|
||||
HostConnection,
|
||||
PermissionReport,
|
||||
PermissionSummary,
|
||||
PolicyFile,
|
||||
} from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||
|
||||
|
||||
const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"];
|
||||
|
||||
const SUMMARY_LABEL: Record<PermissionSummary, string> = {
|
||||
environment_admin: "Environment admin",
|
||||
workspace_user: "Workspace user",
|
||||
insufficient: "Insufficient",
|
||||
};
|
||||
|
||||
const SUMMARY_STYLE: Record<PermissionSummary, string> = {
|
||||
environment_admin: "text-[var(--success)] border-[var(--success)]/40",
|
||||
workspace_user: "text-[var(--accent)] border-[var(--accent)]/40",
|
||||
insufficient: "text-[var(--error)] border-[var(--error)]/40",
|
||||
};
|
||||
|
||||
type AuthChoice = "profile" | "keys";
|
||||
|
||||
/** An error with the step that failed, so Sign in can retry just that step. */
|
||||
interface Failure {
|
||||
error: AwsError;
|
||||
retry: () => Promise<void>;
|
||||
}
|
||||
|
||||
function ErrorLine({
|
||||
failure,
|
||||
onSignIn,
|
||||
signingIn,
|
||||
}: {
|
||||
failure: Failure;
|
||||
/** Absent when there is no saved connection to sign in with. */
|
||||
onSignIn?: () => void;
|
||||
signingIn: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div role="alert" className="text-xs text-[var(--error)] leading-snug flex items-center gap-2 flex-wrap">
|
||||
<span>{awsErrorMessage(failure.error)}</span>
|
||||
{failure.error.kind === "sso_expired" && !onSignIn && (
|
||||
<span>
|
||||
Sign in with <code>aws sso login --profile {failure.error.profile}</code> and try again.
|
||||
</span>
|
||||
)}
|
||||
{failure.error.kind === "sso_expired" && onSignIn && (
|
||||
<Button size="sm" variant="secondary" onClick={onSignIn} disabled={signingIn}>
|
||||
{signingIn ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionReportView({ report }: { report: PermissionReport }) {
|
||||
const [copyNote, setCopyNote] = useState<string | null>(null);
|
||||
const probed = typeof report.method === "object";
|
||||
// Insufficient only because checks couldn't complete (no denied row in the
|
||||
// workspace tier, only rows with a note): don't claim the permissions are missing.
|
||||
const workspaceTier = report.tiers.find((t) => t.tier === "workspace_user");
|
||||
const incomplete =
|
||||
report.summary === "insufficient" &&
|
||||
!!workspaceTier &&
|
||||
!workspaceTier.results.some((r) => r.verdict === "denied") &&
|
||||
workspaceTier.results.some((r) => r.note);
|
||||
const badge = incomplete
|
||||
? "Incomplete — check again"
|
||||
: report.summary === "workspace_user" && probed
|
||||
? "Workspace user (unverified)"
|
||||
: SUMMARY_LABEL[report.summary];
|
||||
const badgeStyle = incomplete
|
||||
? "text-[var(--text-secondary)] border-[var(--border-color)]"
|
||||
: SUMMARY_STYLE[report.summary];
|
||||
const flagged = report.tiers
|
||||
.map((t) => ({ ...t, results: t.results.filter((r) => r.verdict !== "allowed") }))
|
||||
.filter((t) => t.results.length > 0);
|
||||
const ownPrincipalDenied =
|
||||
report.summary === "insufficient" &&
|
||||
report.tiers.some((t) =>
|
||||
t.results.some((r) => r.verdict === "denied" && OWN_PRINCIPAL_ACTIONS.includes(r.action)),
|
||||
);
|
||||
|
||||
const copy = async (file: PolicyFile) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(file.json);
|
||||
setCopyNote(`${file.name} copied to clipboard.`);
|
||||
} catch (e) {
|
||||
setCopyNote(`Could not copy ${file.name}: ${awsErrorMessage(toAwsError(e))}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2" data-testid="permission-report">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-full border ${badgeStyle}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{report.identity.arn} · {report.region}
|
||||
</span>
|
||||
</div>
|
||||
{incomplete && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Some checks couldn't complete (for example because AWS throttled the requests), so the
|
||||
permissions couldn't be fully assessed. Check again in a moment.
|
||||
</p>
|
||||
)}
|
||||
{typeof report.method === "object" && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{`Simulation not permitted (${report.method.probed.reason}). Read access was probed; write actions are unverified and will be confirmed on first use.`}
|
||||
</p>
|
||||
)}
|
||||
{ownPrincipalDenied && (
|
||||
<p className="text-xs text-[var(--warning)] leading-snug">
|
||||
Triple-C couldn't inspect the caller's own permissions. Both policies grant iam:GetRole and
|
||||
iam:SimulatePrincipalPolicy on the caller's own principal, so this is the first thing to ask
|
||||
an admin for.
|
||||
</p>
|
||||
)}
|
||||
{flagged.map((t) => (
|
||||
<details key={t.tier} className="text-xs">
|
||||
<summary className="cursor-pointer text-[var(--text-primary)]">
|
||||
{t.tier === "workspace_user" ? "Workspace user" : "Environment admin"} ({t.results.length} not
|
||||
allowed)
|
||||
</summary>
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
{t.results.map((r) => (
|
||||
<li key={r.action} className="text-[var(--text-secondary)]">
|
||||
<span className="font-mono">{r.action}</span> — {r.verdict}
|
||||
{r.note && (
|
||||
<>
|
||||
{" "}
|
||||
({r.note}). Check again in a moment.
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
<PolicyFiles tier="workspace_user" label="Show workspace-user policy" onCopy={copy} />
|
||||
<PolicyFiles tier="environment_admin" label="Show admin policies" onCopy={copy} />
|
||||
{copyNote && (
|
||||
<p role="status" className="text-xs text-[var(--text-secondary)]">
|
||||
{copyNote}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
The simulation doesn't account for SCPs or session policies.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionRow({ host }: { host: HostConnection }) {
|
||||
const { loadSettings, setAppSettings } = useSettings();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(host.name);
|
||||
const [region, setRegion] = useState(host.kind.default_region);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [report, setReport] = useState<PermissionReport | null>(null);
|
||||
const [failure, setFailure] = useState<Failure | null>(null);
|
||||
const auth = host.kind.auth;
|
||||
const rowRef = useRef<HTMLLIElement>(null);
|
||||
const focusIn = (key: string) =>
|
||||
rowRef.current?.querySelector<HTMLElement>(`[data-focus="${key}"]`)?.focus();
|
||||
useEffect(() => {
|
||||
if (confirmRemove) focusIn("confirm-remove");
|
||||
}, [confirmRemove]);
|
||||
const cancelRemove = () => {
|
||||
setConfirmRemove(false);
|
||||
requestAnimationFrame(() => focusIn("remove"));
|
||||
};
|
||||
|
||||
const runCheck = async () => {
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
setReport(await checkAwsPermissions(host.id));
|
||||
} catch (e) {
|
||||
setReport(null);
|
||||
setFailure({ error: toAwsError(e), retry: runCheck });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async () => {
|
||||
if (!failure) return;
|
||||
const retry = failure.retry;
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await awsConnectionSsoLogin(host.id);
|
||||
setFailure(null);
|
||||
await retry();
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry });
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
await updateAwsConnection(host.id, name.trim(), region.trim());
|
||||
await loadSettings();
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: save });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setAppSettings(await removeHostConnection(host.id));
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: remove });
|
||||
setConfirmRemove(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
data-testid={`host-${host.id}`}
|
||||
className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5 space-y-1.5"
|
||||
>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Field label="Name">{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
|
||||
<Field label="Default region">
|
||||
{(id) => (
|
||||
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="primary" onClick={save} disabled={busy || !name.trim() || !region.trim()}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditing(false); setName(host.name); setRegion(host.kind.default_region); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">{host.name}</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
<span>{auth.method === "profile" ? `Profile: ${auth.name}` : "Access keys"}</span>
|
||||
{" · "}
|
||||
<span>{host.kind.default_region}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={runCheck} disabled={busy} aria-label={`Check ${host.name}`}>
|
||||
Check
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing(true)} aria-label={`Edit ${host.name}`}>
|
||||
Edit
|
||||
</Button>
|
||||
{!confirmRemove && (
|
||||
<Button data-focus="remove" size="sm" variant="danger" onClick={() => setConfirmRemove(true)} aria-label={`Remove ${host.name}`}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{confirmRemove && (
|
||||
<div role="alertdialog" aria-label={`Remove ${host.name}?`} className="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span>Remove {host.name}? Its stored credentials are deleted.</span>
|
||||
<Button data-focus="confirm-remove" size="sm" variant="danger" onClick={remove} disabled={busy} aria-label={`Confirm remove ${host.name}`}>
|
||||
Confirm remove
|
||||
</Button>
|
||||
<Button size="sm" onClick={cancelRemove}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{busy && !editing && !confirmRemove && <p role="status" className="text-xs text-[var(--text-secondary)]">Working…</p>}
|
||||
{failure && <ErrorLine failure={failure} onSignIn={signIn} signingIn={signingIn} />}
|
||||
{report && <PermissionReportView report={report} />}
|
||||
<EnvironmentsPanel connection={host} tier={report?.summary} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function AddConnectionForm() {
|
||||
const { loadSettings } = useSettings();
|
||||
const [profiles, setProfiles] = useState<string[]>([]);
|
||||
const [auth, setAuth] = useState<AuthChoice>("profile");
|
||||
const [name, setName] = useState("");
|
||||
const [profile, setProfile] = useState("");
|
||||
const [accessKeyId, setAccessKeyId] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [region, setRegion] = useState("us-east-1");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [signedInAs, setSignedInAs] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<Failure | null>(null);
|
||||
const [profilesError, setProfilesError] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listAwsProfiles()
|
||||
.then((p) => {
|
||||
if (!cancelled) setProfiles(p);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setProfiles([]);
|
||||
setProfilesError(awsErrorMessage(toAwsError(e)));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ready =
|
||||
name.trim() !== "" &&
|
||||
region.trim() !== "" &&
|
||||
(auth === "profile" ? profile !== "" : accessKeyId.trim() !== "" && secret !== "");
|
||||
|
||||
/** Second step: confirm the new connection can sign in. Retried alone after an SSO login. */
|
||||
const verify = async (id: string) => {
|
||||
try {
|
||||
const who = await awsConnectionIdentity(id);
|
||||
setSignedInAs(who.arn);
|
||||
setFailure(null);
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: () => verify(id) });
|
||||
}
|
||||
};
|
||||
|
||||
/** Everything after a successful add. Retrying never re-runs the add. */
|
||||
const afterAdd = async (id: string) => {
|
||||
const refreshed = await loadSettings();
|
||||
if (!refreshed) {
|
||||
setFailure({
|
||||
error: { kind: "other", message: "The connection was added, but the settings list could not be refreshed." },
|
||||
retry: () => afterAdd(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await verify(id);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
setSignedInAs(null);
|
||||
try {
|
||||
setPendingId(null);
|
||||
const added =
|
||||
auth === "profile"
|
||||
? await addAwsProfileConnection(name.trim(), profile, region.trim())
|
||||
: await addAwsKeysConnection(name.trim(), accessKeyId.trim(), secret, token.trim() || null, region.trim());
|
||||
if (auth === "keys") {
|
||||
setAccessKeyId("");
|
||||
setSecret("");
|
||||
setToken("");
|
||||
}
|
||||
setName("");
|
||||
setPendingId(added.id);
|
||||
await afterAdd(added.id);
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry: submit });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async () => {
|
||||
if (!failure || !pendingId) return;
|
||||
const retry = failure.retry;
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await awsConnectionSsoLogin(pendingId);
|
||||
setFailure(null);
|
||||
await retry();
|
||||
} catch (e) {
|
||||
setFailure({ error: toAwsError(e), retry });
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (ready && !busy) void submit();
|
||||
}}
|
||||
>
|
||||
<h4 className="text-[13px] font-semibold text-[var(--text-primary)]">Add AWS connection</h4>
|
||||
<Field label="Name">
|
||||
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
|
||||
</Field>
|
||||
<SegmentedControl<AuthChoice>
|
||||
label="Authentication"
|
||||
value={auth}
|
||||
onChange={setAuth}
|
||||
segments={[
|
||||
{ value: "profile", label: "Profile" },
|
||||
{ value: "keys", label: "Access keys" },
|
||||
]}
|
||||
/>
|
||||
{auth === "profile" ? (
|
||||
<Field
|
||||
label="Profile"
|
||||
hint={profilesError ? `Couldn't read your AWS profiles: ${profilesError}` : undefined}
|
||||
>
|
||||
{(id) => (
|
||||
<select id={id} className={`${selectClass} w-full`} value={profile} onChange={(e) => setProfile(e.target.value)}>
|
||||
<option value="">Select a profile…</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Access key ID">
|
||||
{(id) => (
|
||||
<input id={id} type="password" autoComplete="off" className={inputClass} value={accessKeyId} onChange={(e) => setAccessKeyId(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Secret access key">
|
||||
{(id) => (
|
||||
<input id={id} type="password" autoComplete="off" className={inputClass} value={secret} onChange={(e) => setSecret(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Session token (optional)" hint="Only for temporary credentials.">
|
||||
{(id) => (
|
||||
<input id={id} type="password" autoComplete="off" className={inputClass} value={token} onChange={(e) => setToken(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Stored in the OS keychain and never shown again.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Field label="Default region">
|
||||
{(id) => (
|
||||
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit" size="md" variant="primary" disabled={!ready || busy}>
|
||||
{busy ? "Adding…" : "Add connection"}
|
||||
</Button>
|
||||
{signedInAs && (
|
||||
<p role="status" className="text-xs text-[var(--success)]">
|
||||
Signed in as {signedInAs}
|
||||
</p>
|
||||
)}
|
||||
{failure && (
|
||||
<ErrorLine failure={failure} onSignIn={pendingId ? signIn : undefined} signingIn={signingIn} />
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HostsSettings() {
|
||||
const { appSettings } = useSettings();
|
||||
const hosts = appSettings?.hosts ?? [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Where project environments run. AWS connections check what your credentials can do, and find, deploy and join environments.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<li className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5">
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">Local Docker</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Always available</div>
|
||||
</li>
|
||||
{hosts.map((h) => (
|
||||
<ConnectionRow key={h.id} host={h} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="pt-3 border-t border-[var(--border-color)]">
|
||||
<AddConnectionForm />
|
||||
</div>
|
||||
<datalist id="aws-regions">
|
||||
{COMMON_REGIONS.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,9 @@ const samplePreview: SettingsImportPreview = {
|
||||
global_hook_install_count: 0,
|
||||
global_plugin_install_count: 0,
|
||||
marketplace_account_token_count: 0,
|
||||
aws_connections: [],
|
||||
aws_connection_key_count: 0,
|
||||
removed_aws_connections: [],
|
||||
};
|
||||
|
||||
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
|
||||
@@ -80,6 +83,50 @@ describe("ImportSettingsModal", () => {
|
||||
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the AWS connections and key count, and flags overwrites", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
aws_connections: [
|
||||
{
|
||||
name: "Prod",
|
||||
auth: "keys",
|
||||
profile_name: null,
|
||||
default_region: "us-west-2",
|
||||
overwrites_existing: true,
|
||||
overwrites_keys: true,
|
||||
keys_check_failed: false,
|
||||
local: { name: "Real Prod", auth: "keys", profile_name: null, default_region: "us-east-1" },
|
||||
},
|
||||
{
|
||||
name: "Dev",
|
||||
auth: "profile",
|
||||
profile_name: "dev-profile",
|
||||
default_region: "eu-west-1",
|
||||
overwrites_existing: false,
|
||||
overwrites_keys: false,
|
||||
keys_check_failed: true,
|
||||
local: null,
|
||||
},
|
||||
],
|
||||
aws_connection_key_count: 1,
|
||||
removed_aws_connections: [
|
||||
{ name: "Old", auth: "profile", profile_name: "old-p", default_region: "ap-south-1" },
|
||||
],
|
||||
});
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(await screen.findByText(/AWS connection "Prod" \(access keys, us-west-2\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AWS connection "Dev" \(profile dev-profile, eu-west-1\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 AWS access key set$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Overwrites the AWS access keys stored for your connection "Real Prod"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Replaces your connection "Real Prod" \(access keys, us-east-1\) with "Prod"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Could not check whether keys are stored for "Dev".*may overwrite them/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Removes your AWS connection "Old" \(profile old-p, ap-south-1\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about a custom Docker image every time, not just on change", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
|
||||
@@ -21,6 +21,7 @@ import WebTerminalSettings from "./WebTerminalSettings";
|
||||
import SttSettings from "./SttSettings";
|
||||
import SharedAuthSettings from "./SharedAuthSettings";
|
||||
import MarketplaceSettings from "./MarketplaceSettings";
|
||||
import HostsSettings from "./HostsSettings";
|
||||
import CertificateSettings from "./CertificateSettings";
|
||||
import ExportSettingsModal from "./ExportSettingsModal";
|
||||
import ImportSettingsModal from "./ImportSettingsModal";
|
||||
@@ -188,6 +189,10 @@ export default function SettingsPanel() {
|
||||
<GatewaySettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="hosts" title="Hosts" defaultOpen={false}>
|
||||
<HostsSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="container" title="Container" defaultOpen={false}>
|
||||
<DockerSettings />
|
||||
</AccordionSection>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import AdminBundleModal from "./AdminBundleModal";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
|
||||
}));
|
||||
|
||||
describe("AdminBundleModal", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("saves the bundle and shows the folder and the two commands, with Copy", async () => {
|
||||
invoke.mockResolvedValue("/home/me/triple-c-onboard-dev");
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
render(
|
||||
<AdminBundleModal connectionId="h1" region="eu-west-1" kind="user_onboard" environment="dev" ownerId="AIDAEXAMPLE" onClose={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByRole("dialog", { name: "Onboarding bundle" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/environment admin/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
expect(await screen.findByText("/home/me/triple-c-onboard-dev")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
kind: "user_onboard",
|
||||
environment: "dev",
|
||||
ownerId: "AIDAEXAMPLE",
|
||||
network: null,
|
||||
});
|
||||
const commands = "tofu init -backend-config=backend.hcl\ntofu apply";
|
||||
expect(screen.getByText((_, el) => el?.tagName === "PRE" && el.textContent === commands)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy commands" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(commands));
|
||||
});
|
||||
|
||||
it("shows nothing new when the folder picker is dismissed", async () => {
|
||||
invoke.mockResolvedValue(null);
|
||||
render(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="permissions_boundary" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Copy commands" })).toBeNull();
|
||||
});
|
||||
|
||||
it("an environment bundle asks for the name and network", async () => {
|
||||
invoke.mockResolvedValue("/tmp/env");
|
||||
render(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="environment" onClose={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "team" } });
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
kind: "environment",
|
||||
environment: "team",
|
||||
ownerId: null,
|
||||
network: { mode: "private-nat" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a save failure through the AWS error copy", async () => {
|
||||
invoke.mockRejectedValue({ kind: "sso_expired", profile: "prod-sso" });
|
||||
render(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="permissions_boundary" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
expect(await screen.findByText("Your SSO session for profile prod-sso has expired.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { saveAwsAdminBundle } from "../../../lib/tauri-commands";
|
||||
import { awsErrorMessage, toAwsError } from "../../../lib/aws-error";
|
||||
import type { AwsError, ModuleKind, NetworkChoice } from "../../../lib/types";
|
||||
import Button from "../../ui/Button";
|
||||
import Field, { inputClass } from "../../ui/Field";
|
||||
import Modal from "../../ui/Modal";
|
||||
import NetworkFields from "./NetworkFields";
|
||||
import { DEFAULT_NETWORK, environmentNameError, networkFromDraft, type NetworkDraft } from "./environmentFormat";
|
||||
|
||||
const COMMANDS = "tofu init -backend-config=backend.hcl\ntofu apply";
|
||||
|
||||
const TITLE: Record<ModuleKind, string> = {
|
||||
environment: "Environment bundle",
|
||||
user_onboard: "Onboarding bundle",
|
||||
permissions_boundary: "Permissions boundary bundle",
|
||||
};
|
||||
|
||||
/**
|
||||
* An admin request (D5): Triple-C writes the module, `backend.hcl`,
|
||||
* `terraform.tfvars.json` and a README into a folder picked in Rust, and the
|
||||
* admin runs the two commands from there.
|
||||
*/
|
||||
export default function AdminBundleModal({
|
||||
connectionId,
|
||||
region,
|
||||
kind,
|
||||
environment,
|
||||
ownerId,
|
||||
network,
|
||||
reason,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
connectionId: string;
|
||||
region: string;
|
||||
kind: ModuleKind;
|
||||
/** Fixed environment; when absent for an environment bundle, the user picks it here. */
|
||||
environment?: string;
|
||||
ownerId?: string;
|
||||
network?: NetworkChoice;
|
||||
/** Why the bundle is needed (e.g. the refusal that led here). */
|
||||
reason?: string;
|
||||
/** Called with the folder once a bundle was actually written. */
|
||||
onSaved?: (folder: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const askEnvironment = kind === "environment" && !environment;
|
||||
const [name, setName] = useState("main");
|
||||
const [draft, setDraft] = useState<NetworkDraft>(DEFAULT_NETWORK);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [error, setError] = useState<AwsError | null>(null);
|
||||
const [copyNote, setCopyNote] = useState<string | null>(null);
|
||||
|
||||
const nameError = askEnvironment ? environmentNameError(name) : null;
|
||||
const picked = askEnvironment ? networkFromDraft(draft) : null;
|
||||
const ready = !nameError && (!picked || "network" in picked);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const folder = await saveAwsAdminBundle(connectionId, region, kind, {
|
||||
environment: askEnvironment ? name : environment,
|
||||
ownerId,
|
||||
network: picked && "network" in picked ? picked.network : network,
|
||||
});
|
||||
if (folder) {
|
||||
setSaved(folder);
|
||||
onSaved?.(folder);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(toAwsError(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(COMMANDS);
|
||||
setCopyNote("Commands copied.");
|
||||
} catch (e) {
|
||||
setCopyNote(`Could not copy: ${awsErrorMessage(toAwsError(e))}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={TITLE[kind]}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button size="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{reason && (
|
||||
<p role="alert" className="text-[var(--warning)]">
|
||||
Doing this from here was refused: {reason}
|
||||
</p>
|
||||
)}
|
||||
{kind === "environment" && (
|
||||
<p>
|
||||
Deploying an environment needs an environment admin. Triple-C writes the OpenTofu module with its
|
||||
backend settings and variables into a folder; send it to an admin, who deploys it from there.
|
||||
</p>
|
||||
)}
|
||||
{kind === "user_onboard" && (
|
||||
<p>
|
||||
Joining {environment} needs your own task role and file system, which an environment admin creates
|
||||
by applying this bundle. Send them the folder; once it's applied, Detect again shows you as joined.
|
||||
</p>
|
||||
)}
|
||||
{kind === "permissions_boundary" && (
|
||||
<p>
|
||||
Every role Triple-C creates is capped by the account's permissions boundary, and creating it needs an
|
||||
account administrator. Send them this folder.
|
||||
</p>
|
||||
)}
|
||||
<p>The bundle works with OpenTofu or Terraform 1.10 and later. Its README lists every step.</p>
|
||||
{askEnvironment && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Name" hint={nameError ?? undefined}>
|
||||
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
|
||||
</Field>
|
||||
<NetworkFields value={draft} onChange={setDraft} error={picked && "error" in picked ? picked.error : null} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="md" variant="primary" onClick={save} disabled={saving || !ready}>
|
||||
{saving ? "Saving…" : "Save bundle to folder…"}
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{awsErrorMessage(error)}
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<div className="space-y-1.5">
|
||||
<p>
|
||||
Saved to <code className="font-mono text-[var(--text-primary)] break-all">{saved}</code>
|
||||
</p>
|
||||
<p>From that folder, run:</p>
|
||||
<pre className="font-mono text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 whitespace-pre-wrap">
|
||||
{COMMANDS}
|
||||
</pre>
|
||||
<Button size="sm" onClick={copy}>
|
||||
Copy commands
|
||||
</Button>
|
||||
{copyNote && <p role="status">{copyNote}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import DeployEnvironmentModal from "./DeployEnvironmentModal";
|
||||
import type { DeployProgress } from "../../../lib/types";
|
||||
import { conn, env, progress, scan, tofuEvent } from "./testkit";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
|
||||
}));
|
||||
|
||||
type Handler = (e: { payload: DeployProgress }) => void;
|
||||
const handlers = new Set<Handler>();
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_name: string, handler: Handler) => {
|
||||
handlers.add(handler);
|
||||
return () => handlers.delete(handler);
|
||||
}),
|
||||
}));
|
||||
function emit(p: DeployProgress) {
|
||||
act(() => {
|
||||
for (const h of [...handlers]) h({ payload: p });
|
||||
});
|
||||
}
|
||||
|
||||
const BOUNDARY_KEY = "boundary/terraform.tfstate";
|
||||
|
||||
function renderModal(over: Partial<Parameters<typeof DeployEnvironmentModal>[0]> = {}) {
|
||||
const props = {
|
||||
connectionId: conn.id,
|
||||
region: "eu-west-1",
|
||||
scan: scan(),
|
||||
onClose: vi.fn(),
|
||||
onDeploy: vi.fn().mockResolvedValue(null),
|
||||
onDetect: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
render(<DeployEnvironmentModal {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("DeployEnvironmentModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
handlers.clear();
|
||||
});
|
||||
|
||||
it("the public network mode is the default and says it costs nothing", async () => {
|
||||
const props = renderModal();
|
||||
expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByText("$0 — tasks get a public IP, no inbound access")).toBeInTheDocument();
|
||||
expect(screen.getByText("First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB).")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" }));
|
||||
expect(screen.getByText("about $33/month for the NAT gateway")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Public" }));
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("main");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deploy" }));
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("main", { mode: "public" });
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("validates the name with the backend's rule", async () => {
|
||||
const props = renderModal({ scan: scan({ environments: [env({ name: "main" })] }) });
|
||||
const deploy = screen.getByRole("button", { name: "Deploy" });
|
||||
expect(screen.getByText(/already exists/)).toBeInTheDocument();
|
||||
expect(deploy).toBeDisabled();
|
||||
for (const bad of ["Dev", "a--b", "dev-", "1dev", "a".repeat(21), ""]) {
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: bad } });
|
||||
expect(deploy).toBeDisabled();
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "team-2" } });
|
||||
expect(deploy).toBeEnabled();
|
||||
fireEvent.click(deploy);
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("team-2", { mode: "public" });
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("an existing network needs a VPC and two distinct subnets", async () => {
|
||||
const props = renderModal();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Existing VPC" }));
|
||||
const deploy = screen.getByRole("button", { name: "Deploy" });
|
||||
expect(deploy).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("VPC ID"), { target: { value: "vpc-0123abcd" } });
|
||||
fireEvent.change(screen.getByLabelText("Subnet ID 1"), { target: { value: "subnet-0123abcd" } });
|
||||
fireEvent.change(screen.getByLabelText("Subnet ID 2"), { target: { value: "subnet-0123abcd" } });
|
||||
expect(deploy).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("Subnet ID 2"), { target: { value: "subnet-4567abcd" } });
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Assign a public IP" }));
|
||||
fireEvent.click(deploy);
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("main", {
|
||||
mode: "existing",
|
||||
vpc_id: "vpc-0123abcd",
|
||||
subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"],
|
||||
assign_public_ip: true,
|
||||
});
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps the dialog open and shows the server's refusal", async () => {
|
||||
const props = renderModal({
|
||||
onDeploy: vi.fn().mockResolvedValue({ kind: "other", message: "A deployment is starting for environment \"main\"; wait for it" }),
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deploy" }));
|
||||
expect(await screen.findByText(/A deployment is starting/)).toBeInTheDocument();
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deploy modal blocks on a missing boundary and offers to create it", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "deploy_aws_permissions_boundary") return "b1";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const props = renderModal({ scan: scan({ boundary: "no" }) });
|
||||
expect(screen.queryByLabelText("Name")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Deploy" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
|
||||
// Running (op id known) is the visible sign the invoke resolved.
|
||||
expect(await screen.findByText("Running")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" });
|
||||
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, events: [tofuEvent("Creating boundary")] }));
|
||||
expect(screen.getByText("Creating boundary")).toBeInTheDocument();
|
||||
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete!" } }));
|
||||
expect(await screen.findByLabelText("Name")).toBeInTheDocument();
|
||||
expect(props.onDetect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the boundary bundle when the boundary run fails, with its summary as the reason", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "deploy_aws_permissions_boundary") return "b1";
|
||||
if (cmd === "save_aws_admin_bundle") return "/tmp/b";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const props = renderModal({ scan: scan({ boundary: "no" }) });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
|
||||
expect(await screen.findByText("Running")).toBeInTheDocument();
|
||||
const summary = "Error: creating IAM Policy (triple-c-permissions-boundary): AccessDenied: not authorized to perform iam:CreatePolicy";
|
||||
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: false, exit_code: 1, summary } }));
|
||||
expect(props.onDetect).toHaveBeenCalled();
|
||||
// Offered, not opened.
|
||||
expect(screen.queryByRole("dialog", { name: "Permissions boundary bundle" })).toBeNull();
|
||||
expect(screen.queryByLabelText("Name")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Get the boundary bundle for an administrator" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Permissions boundary bundle" });
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent(summary);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" }));
|
||||
expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the boundary bundle when the boundary command itself is denied", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "deploy_aws_permissions_boundary") throw { kind: "access_denied", action: "iam:CreatePolicy", message: "denied iam:CreatePolicy" };
|
||||
if (cmd === "save_aws_admin_bundle") return "/tmp/b";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
renderModal({ scan: scan({ boundary: "no" }) });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Permissions boundary bundle" });
|
||||
expect(within(dialog).getByText(/denied iam:CreatePolicy/)).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
kind: "permissions_boundary",
|
||||
environment: null,
|
||||
ownerId: null,
|
||||
network: null,
|
||||
}),
|
||||
);
|
||||
expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("repairs a named environment and warns that a different network replaces it", async () => {
|
||||
const props = renderModal({ repair: "dev" });
|
||||
expect(screen.getByRole("dialog", { name: "Repair dev" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Name")).toBeNull();
|
||||
expect(screen.getByText(/different network than the one it was deployed with replaces/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Repair" }));
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("dev", { mode: "private-nat" });
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user