Compare commits
99
Commits
No files matched your search
@@ -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
|
||||
@@ -262,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: |
|
||||
@@ -288,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
|
||||
;;
|
||||
*)
|
||||
@@ -340,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 }}
|
||||
@@ -429,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: |
|
||||
@@ -455,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
|
||||
;;
|
||||
*)
|
||||
@@ -717,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 }}
|
||||
@@ -741,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 `
|
||||
@@ -778,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
|
||||
@@ -788,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`
|
||||
|
||||
@@ -20,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
|
||||
@@ -287,6 +287,19 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
`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.
|
||||
`aws_host.rs` is `ProjectHost::Aws` (an ECS Fargate task per project; a running task is
|
||||
attached to with the token read back from its secret, never rotated; a task a failed start ran
|
||||
is stopped again) and `tunnels.rs` the registry of open tunnels by project — an AWS project's
|
||||
`ContainerTarget::for_project` is its tunnel's endpoint, its stored `container_id` is that
|
||||
endpoint's URL, and the AWS death watch confirms exits through the registry. Shutdown closes
|
||||
every tunnel (`for_project` is `None` while none is open; `for_project_waiting` covers the
|
||||
background re-attach after an app restart). AWS SDK calls stay in `aws/` (`runtime.rs`,
|
||||
`tasks.rs`, `workspace.rs`).
|
||||
`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
|
||||
@@ -299,6 +312,71 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
`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 — both tiers span 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.
|
||||
**A Fargate workspace's resources** (`workspace.rs`, `tasks.rs`, `runtime.rs`). **No project
|
||||
data in a task definition (P1):** every workspace user has `ecs:Describe*` on `*`, so
|
||||
`environment`, tags, labels and `overrides` are public to the account. The task definition carries
|
||||
`TRIPLEC_HOST=aws`, `COLORTERM`, the three ownership tags, a fingerprint docker label (a hash,
|
||||
`FINGERPRINT_LABEL`) and `valueFrom` references into the project's **one secret**
|
||||
(`triple-c/<env>/<owner_key>/<project-id>`, JSON, 64 KB cap: files, repos, instructions, settings,
|
||||
custom env, the agent token). The token rotates each start; a running task is attached to by reading
|
||||
it back (the task role has no Secrets Manager access). **Anyone may register a revision into any
|
||||
`triple-c-*` family** (IAM can't scope it), so a revision or task is reused only when it carries the
|
||||
caller's own owner tag + this project's tag (+ the fingerprint), and `RunTask` always names the exact
|
||||
`family:revision` ARN, never "latest". Names come only from `tofu_modules.rs`, built from `owner_key`
|
||||
(`aws:userid` contains `:`). Workspace users can't deregister or delete task definitions
|
||||
(`DeregisterTaskDefinition` is `*`-only in IAM): removal leaves revisions ACTIVE for an admin
|
||||
(`aws/README.md`). Stop: the stop guard (`commands/stop_guard.rs`) dry-runs `triple-c-wip-push`
|
||||
through the agent, closes the project's terminals and auth bridge (so nothing is written after the
|
||||
push's snapshot), may push first (under an in-container `timeout`, since the agent does not kill an
|
||||
exec the client gave up on), then writes `pushed` or `discard` to `/run/triple-c/stop-reason`
|
||||
(root-owned; the entrypoint's TERM hook reads it, and the idle watcher writes `idle`) so the hook
|
||||
doesn't push twice; the marker is cleared if StopTask fails. Claude Code's sandbox is forced off in
|
||||
the generated settings (bubblewrap can't create namespaces on Fargate, verified live).
|
||||
**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
|
||||
@@ -311,6 +389,29 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
|
||||
libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled`
|
||||
toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`)
|
||||
- **`TRIPLEC_HOST=aws` mode** (`entrypoint.sh`, the `aws_*` blocks; inert without it). There are no
|
||||
bind mounts: `TRIPLEC_FILES` (JSON of base64 files) is decoded into `/tmp/.host-ssh` / `/tmp/.host-ca`
|
||||
so the SSH merge and CA install run unchanged, and `TRIPLEC_REPOS` is kept in an unexported shell
|
||||
variable; **both are unset before the agent-token block snapshots the environment** (that snapshot is
|
||||
every terminal's env and the files hold private keys). Repos clone in the background after the agent
|
||||
starts (terminals are usable before they arrive). Hybrid storage mounts EFS at `/mnt/triple-c-home`
|
||||
and symlinks `~/.claude`, `~/.claude.json`, `~/.ssh` into it (mounting over `/home/claude` would hide
|
||||
the image's tooling); root never follows those links outside the mount (`aws_hybrid_ssh_dir`). On
|
||||
TERM the hook pushes unsaved work to a `triple-c/wip/<stamp>` branch via a temporary index (the
|
||||
user's index is untouched; needs the task definition's `stopTimeout` raised, 120 s) unless the stop
|
||||
marker says the app handled it. The idle watcher runs as root under a supervisor and signals the
|
||||
entrypoint's own PID (not PID 1, which is docker-init), so idle and ECS stops share one trap.
|
||||
Idle = the agent's `last_activity_unix` older than N minutes (open-but-silent attaches/tunnels
|
||||
don't count; requests with `X-Triple-C-Background: 1` — the auth bridge's and the file viewer's polls — never do),
|
||||
no `claude`, no scheduler job, no running clone, and no unsaved git work: with the WIP push on the
|
||||
watcher pushes *before* signalling and stays up if that fails; with it off, dirty repos keep the
|
||||
workspace up; activity during the watcher's push sends it back to watching. A repo the background
|
||||
clone is still writing (`/run/triple-c-clone/repos` last says `cloning`, no `done`) is skipped by
|
||||
`triple-c-wip-push` (dry run: `"cloning": true`). The stop trap is set before any background work
|
||||
starts. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it
|
||||
from a crash (`classify_stop` → the `ContainerExited` toast).
|
||||
**The `TRIPLEC_` env prefix is reserved** on both hosts (`RESERVED_ENV_PREFIXES`; `workspace.rs`
|
||||
drops custom variables in it): the entrypoint's settings live there.
|
||||
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
|
||||
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
|
||||
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||
@@ -788,6 +889,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
|
||||
@@ -907,6 +1014,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
|
||||
@@ -952,8 +1078,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,
|
||||
@@ -986,8 +1112,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:
|
||||
|
||||
|
||||
Generated
+902
-27
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,9 @@ 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"
|
||||
@@ -55,6 +58,34 @@ 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"] }
|
||||
aws-sdk-efs = { version = "1.125.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-cloudwatchlogs = { version = "1.165.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
|
||||
@@ -67,6 +98,11 @@ webkit2gtk = "2.0.2"
|
||||
# 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 = [] }
|
||||
|
||||
@@ -18,8 +18,12 @@
|
||||
"allow-update-project",
|
||||
"allow-start-project-container",
|
||||
"allow-stop-project-container",
|
||||
"allow-check-unsaved-work",
|
||||
"allow-aws-task-log",
|
||||
"allow-remove-aws-resources",
|
||||
"allow-rebuild-project-container",
|
||||
"allow-reconcile-project-statuses",
|
||||
"allow-reconcile-aws-projects",
|
||||
"allow-list-notes",
|
||||
"allow-save-note",
|
||||
"allow-delete-note",
|
||||
@@ -57,6 +61,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-aws-task-log":{"identifier":"allow-aws-task-log","description":"Enables the aws_task_log command without any pre-configured scope.","commands":{"allow":["aws_task_log"],"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":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,12 +404,36 @@
|
||||
"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",
|
||||
"const": "allow-aws-sso-refresh",
|
||||
"markdownDescription": "Enables the aws_sso_refresh command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_task_log command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-task-log",
|
||||
"markdownDescription": "Enables the aws_task_log command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the build_gateway_image command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -416,6 +452,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 +470,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",
|
||||
@@ -464,6 +512,12 @@
|
||||
"const": "allow-check-image-update",
|
||||
"markdownDescription": "Enables the check_image_update command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_unsaved_work command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-unsaved-work",
|
||||
"markdownDescription": "Enables the check_unsaved_work command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the clear_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -518,12 +572,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 +632,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 +854,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 +920,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",
|
||||
@@ -842,6 +938,12 @@
|
||||
"const": "allow-rebuild-project-container",
|
||||
"markdownDescription": "Enables the rebuild_project_container command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reconcile_aws_projects command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-reconcile-aws-projects",
|
||||
"markdownDescription": "Enables the reconcile_aws_projects command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reconcile_project_statuses command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +968,18 @@
|
||||
"const": "allow-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_aws_resources command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-remove-aws-resources",
|
||||
"markdownDescription": "Enables the remove_aws_resources 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 +1010,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 +1034,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 +1208,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 +1304,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,12 +1358,36 @@
|
||||
"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",
|
||||
"const": "deny-aws-sso-refresh",
|
||||
"markdownDescription": "Denies the aws_sso_refresh command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_task_log command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-task-log",
|
||||
"markdownDescription": "Denies the aws_task_log command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the build_gateway_image command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1226,6 +1406,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 +1424,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",
|
||||
@@ -1274,6 +1466,12 @@
|
||||
"const": "deny-check-image-update",
|
||||
"markdownDescription": "Denies the check_image_update command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_unsaved_work command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-unsaved-work",
|
||||
"markdownDescription": "Denies the check_unsaved_work command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the clear_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1328,12 +1526,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 +1586,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 +1808,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 +1874,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",
|
||||
@@ -1652,6 +1892,12 @@
|
||||
"const": "deny-rebuild-project-container",
|
||||
"markdownDescription": "Denies the rebuild_project_container command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reconcile_aws_projects command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-reconcile-aws-projects",
|
||||
"markdownDescription": "Denies the reconcile_aws_projects command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reconcile_project_statuses command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1676,6 +1922,18 @@
|
||||
"const": "deny-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_aws_resources command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-remove-aws-resources",
|
||||
"markdownDescription": "Denies the remove_aws_resources 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 +1964,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 +1988,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 +2162,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,12 +404,36 @@
|
||||
"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",
|
||||
"const": "allow-aws-sso-refresh",
|
||||
"markdownDescription": "Enables the aws_sso_refresh command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the aws_task_log command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-aws-task-log",
|
||||
"markdownDescription": "Enables the aws_task_log command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the build_gateway_image command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -416,6 +452,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 +470,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",
|
||||
@@ -464,6 +512,12 @@
|
||||
"const": "allow-check-image-update",
|
||||
"markdownDescription": "Enables the check_image_update command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_unsaved_work command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-unsaved-work",
|
||||
"markdownDescription": "Enables the check_unsaved_work command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the clear_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -518,12 +572,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 +632,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 +854,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 +920,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",
|
||||
@@ -842,6 +938,12 @@
|
||||
"const": "allow-rebuild-project-container",
|
||||
"markdownDescription": "Enables the rebuild_project_container command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reconcile_aws_projects command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-reconcile-aws-projects",
|
||||
"markdownDescription": "Enables the reconcile_aws_projects command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reconcile_project_statuses command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +968,18 @@
|
||||
"const": "allow-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_aws_resources command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-remove-aws-resources",
|
||||
"markdownDescription": "Enables the remove_aws_resources 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 +1010,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 +1034,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 +1208,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 +1304,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,12 +1358,36 @@
|
||||
"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",
|
||||
"const": "deny-aws-sso-refresh",
|
||||
"markdownDescription": "Denies the aws_sso_refresh command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the aws_task_log command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-aws-task-log",
|
||||
"markdownDescription": "Denies the aws_task_log command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the build_gateway_image command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1226,6 +1406,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 +1424,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",
|
||||
@@ -1274,6 +1466,12 @@
|
||||
"const": "deny-check-image-update",
|
||||
"markdownDescription": "Denies the check_image_update command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_unsaved_work command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-unsaved-work",
|
||||
"markdownDescription": "Denies the check_unsaved_work command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the clear_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1328,12 +1526,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 +1586,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 +1808,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 +1874,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",
|
||||
@@ -1652,6 +1892,12 @@
|
||||
"const": "deny-rebuild-project-container",
|
||||
"markdownDescription": "Denies the rebuild_project_container command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reconcile_aws_projects command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-reconcile-aws-projects",
|
||||
"markdownDescription": "Denies the reconcile_aws_projects command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reconcile_project_statuses command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1676,6 +1922,18 @@
|
||||
"const": "deny-regenerate-web-terminal-token",
|
||||
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_aws_resources command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-remove-aws-resources",
|
||||
"markdownDescription": "Denies the remove_aws_resources 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 +1964,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 +1988,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 +2162,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",
|
||||
|
||||
@@ -326,7 +326,8 @@ 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 = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
|
||||
// Background: on AWS this poll must not keep an idle workspace up.
|
||||
res = container.exec_oneshot_background("claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
|
||||
res.map(|(out, _)| out)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! 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::timeout::TimeoutConfig;
|
||||
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 and stalled-stream protection are each service client's
|
||||
/// defaults (the `default-https-client` stack every AWS crate here is built
|
||||
/// with). Timeouts are ours: [`OPERATION_TIMEOUT`] / [`ATTEMPT_TIMEOUT`].
|
||||
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())
|
||||
.timeout_config(timeouts())
|
||||
.time_source(SharedTimeSource::default());
|
||||
if let Some(sleep) = default_async_sleep() {
|
||||
builder = builder.sleep_impl(sleep);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Every call the app makes through the SDK is a control-plane call with a
|
||||
/// small answer (ECS, Secrets Manager, EFS, IAM, SSM, CloudWatch Logs, STS,
|
||||
/// and S3 reads of state objects capped at 2 KiB); OpenTofu talks to AWS
|
||||
/// itself, not through these clients. Without a bound a half-open
|
||||
/// connection would hang a start, a stop or the death watch for minutes.
|
||||
/// The operation bound covers every retry; the attempt bound is one try.
|
||||
pub const OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
pub const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
fn timeouts() -> TimeoutConfig {
|
||||
TimeoutConfig::builder()
|
||||
.operation_timeout(OPERATION_TIMEOUT)
|
||||
.operation_attempt_timeout(ATTEMPT_TIMEOUT)
|
||||
.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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sdk_call_is_bounded() {
|
||||
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));
|
||||
let t = cfg.timeout_config().expect("a timeout config");
|
||||
assert_eq!(t.operation_timeout(), Some(OPERATION_TIMEOUT));
|
||||
assert_eq!(t.operation_attempt_timeout(), Some(ATTEMPT_TIMEOUT));
|
||||
assert!(ATTEMPT_TIMEOUT < OPERATION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,164 @@
|
||||
//! 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,
|
||||
},
|
||||
/// An account limit (Fargate vCPUs, tasks, ...) refused the request.
|
||||
/// Never retried; `message` says what to do.
|
||||
QuotaExceeded {
|
||||
message: 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::QuotaExceeded { message } | 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,24 @@
|
||||
//! 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 runtime;
|
||||
pub mod tasks;
|
||||
pub mod tofu;
|
||||
pub mod tofu_modules;
|
||||
pub mod workspace;
|
||||
|
||||
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,560 @@
|
||||
//! 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.
|
||||
//
|
||||
// The connection's 30 s operation limit counts the retries' backoff
|
||||
// too, which would cut those ten attempts to about six; the check
|
||||
// gets its own, longer limit, and the UI says it can take a while.
|
||||
let iam = aws_sdk_iam::config::Builder::from(cfg)
|
||||
.retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10))
|
||||
.timeout_config(iam_check_timeouts())
|
||||
.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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole of one simulation, retries and backoff included. Ten adaptive
|
||||
/// attempts with backoff capped at 20 s fit in two minutes.
|
||||
pub const IAM_CHECK_OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
fn iam_check_timeouts() -> aws_config::timeout::TimeoutConfig {
|
||||
aws_config::timeout::TimeoutConfig::builder()
|
||||
.operation_timeout(IAM_CHECK_OPERATION_TIMEOUT)
|
||||
.operation_attempt_timeout(super::connection::ATTEMPT_TIMEOUT)
|
||||
.build()
|
||||
}
|
||||
|
||||
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,640 @@
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_iam_check_gets_room_for_its_throttled_retries() {
|
||||
let t = iam_check_timeouts();
|
||||
assert_eq!(t.operation_timeout(), Some(IAM_CHECK_OPERATION_TIMEOUT));
|
||||
assert!(IAM_CHECK_OPERATION_TIMEOUT > crate::aws::connection::OPERATION_TIMEOUT);
|
||||
// Ten adaptive attempts, backoff capped at 20 s, fit inside it.
|
||||
assert!(IAM_CHECK_OPERATION_TIMEOUT >= std::time::Duration::from_secs(9 * 20 / 2 + 20));
|
||||
assert_eq!(t.operation_attempt_timeout(), Some(crate::aws::connection::ATTEMPT_TIMEOUT));
|
||||
}
|
||||
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"))
|
||||
])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
//! What the AWS host asks AWS around a task, besides running it
|
||||
//! (`tasks.rs`) and preparing its resources (`workspace.rs`): the
|
||||
//! environment's record, whether the caller is onboarded, the agent token of
|
||||
//! a running task (P2), waiting for a new access point, and removing a
|
||||
//! project's resources.
|
||||
//!
|
||||
//! Like the other `aws/` modules, the decisions are made by the functions
|
||||
//! here over a small trait ([`RuntimeApi`]) so a fake exercises them; the SDK
|
||||
//! calls are in [`SdkRuntimeApi`].
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use aws_sdk_secretsmanager::error::ProvideErrorMetadata;
|
||||
|
||||
use super::environments::{knowable, parse_environment_record, EnvironmentInfo, Knowable};
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
use super::tasks::{find_project_task, TaskApi, STOP_REASON};
|
||||
use super::tofu_modules::{
|
||||
access_point_root, environment_cluster_name, environment_parameter_name, project_secret_name, task_family,
|
||||
user_file_system_token, user_role_names,
|
||||
};
|
||||
use super::workspace::WorkspaceApi;
|
||||
|
||||
/// The secret key holding the agent token (Task 4's payload).
|
||||
pub const AGENT_TOKEN_KEY: &str = "TRIPLEC_AGENT_TOKEN";
|
||||
/// A project secret is at most 64 KiB; anything bigger is not ours.
|
||||
const MAX_SECRET_READ: usize = 70_000;
|
||||
|
||||
fn other(message: impl Into<String>) -> AwsError {
|
||||
AwsError::Other {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RuntimeApi: Send + Sync {
|
||||
/// `ssm:GetParameter` of an environment's record; `None` when absent.
|
||||
fn environment_record(
|
||||
&self,
|
||||
env: &str,
|
||||
) -> impl Future<Output = Result<Option<String>, AwsError>> + Send;
|
||||
/// `iam:GetRole`.
|
||||
fn role_exists(&self, name: &str) -> impl Future<Output = Result<Knowable, AwsError>> + Send;
|
||||
/// `secretsmanager:GetSecretValue`'s `SecretString`.
|
||||
fn secret_string(&self, name: &str) -> impl Future<Output = Result<String, AwsError>> + Send;
|
||||
/// The access point's `LifeCycleState`; `None` when it is gone.
|
||||
fn access_point_state(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> impl Future<Output = Result<Option<String>, AwsError>> + Send;
|
||||
/// Delete at once (`ForceDeleteWithoutRecovery`, ruling R10): a secret
|
||||
/// left pending deletion would make the project's next start fail, and
|
||||
/// the app rebuilds it from local data. A secret that does not exist is
|
||||
/// success.
|
||||
fn delete_secret(&self, name: &str) -> impl Future<Output = Result<(), AwsError>> + Send;
|
||||
/// A missing access point is success.
|
||||
fn delete_access_point(&self, id: &str) -> impl Future<Output = Result<(), AwsError>> + Send;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ environment
|
||||
|
||||
/// The environment's record, read with one `GetParameter` and validated.
|
||||
pub async fn read_environment<R: RuntimeApi>(
|
||||
rt: &R,
|
||||
env: &str,
|
||||
account: &str,
|
||||
region: &str,
|
||||
) -> Result<EnvironmentInfo, AwsError> {
|
||||
let value = rt.environment_record(env).await?.ok_or_else(|| {
|
||||
other(format!(
|
||||
"The environment \"{env}\" was not found in {region}. It may have been removed; \
|
||||
check Settings → Hosts."
|
||||
))
|
||||
})?;
|
||||
parse_environment_record(env, &value, account, region).map_err(|e| {
|
||||
other(format!(
|
||||
"The record of environment \"{env}\" in {region} is not usable: {e}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the caller's task role exists in the environment: the onboarding
|
||||
/// signal a workspace user can read. A denied lookup is not an answer, so
|
||||
/// it is let through (RunTask will say what is wrong).
|
||||
pub async fn check_onboarded<R: RuntimeApi>(
|
||||
rt: &R,
|
||||
env: &str,
|
||||
owner_key: &str,
|
||||
) -> Result<(), AwsError> {
|
||||
let (task_role, _) = user_role_names(env, owner_key);
|
||||
match rt.role_exists(&task_role).await? {
|
||||
Knowable::No => Err(other(format!(
|
||||
"You have not joined the environment \"{env}\" yet. Join it in Settings → Hosts \
|
||||
(or ask an administrator to onboard you), then start the project again."
|
||||
))),
|
||||
Knowable::Yes | Knowable::Unknown => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent token in a project secret's JSON. The error never echoes the
|
||||
/// secret.
|
||||
pub fn agent_token_from_secret(json: &str) -> Result<String, String> {
|
||||
if json.len() > MAX_SECRET_READ {
|
||||
return Err("The project secret is larger than a project secret can be".into());
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|_| "The project secret is not a JSON object".to_string())?;
|
||||
v.get(AGENT_TOKEN_KEY)
|
||||
.and_then(|t| t.as_str())
|
||||
.filter(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_graphic()))
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "The project secret holds no agent token".to_string())
|
||||
}
|
||||
|
||||
/// The token a running task of this project started with (P2: the caller
|
||||
/// may read their own secret; `prepare_workspace` wrote it before RunTask).
|
||||
pub async fn read_agent_token<R: RuntimeApi>(
|
||||
rt: &R,
|
||||
env: &str,
|
||||
owner_key: &str,
|
||||
project_id: &str,
|
||||
) -> Result<String, AwsError> {
|
||||
let name = project_secret_name(env, owner_key, project_id);
|
||||
let json = rt.secret_string(&name).await?;
|
||||
agent_token_from_secret(&json).map_err(other)
|
||||
}
|
||||
|
||||
/// After a start this call ran is up and its agent answered with `ours`:
|
||||
/// make sure the secret still holds `ours`. A start that lost the RunTask
|
||||
/// race (another machine, the same moment) rotated the token in the secret
|
||||
/// before it found this task, and every later attach would read a token the
|
||||
/// task never saw. Returns whether the secret had to be put back.
|
||||
pub async fn ensure_agent_token<W: WorkspaceApi, R: RuntimeApi>(
|
||||
work: &W,
|
||||
rt: &R,
|
||||
env: &EnvironmentInfo,
|
||||
owner_user_id: &str,
|
||||
owner_key: &str,
|
||||
project_id: &str,
|
||||
ours: &str,
|
||||
) -> Result<bool, AwsError> {
|
||||
let name = project_secret_name(&env.name, owner_key, project_id);
|
||||
let json = rt.secret_string(&name).await?;
|
||||
if agent_token_from_secret(&json).ok().as_deref() == Some(ours) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut v: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json)
|
||||
.map_err(|_| other("The project secret is not a JSON object"))?;
|
||||
v.insert(AGENT_TOKEN_KEY.to_string(), serde_json::Value::String(ours.to_string()));
|
||||
let repaired = serde_json::to_string(&v).map_err(|e| other(e.to_string()))?;
|
||||
let tags = super::workspace::workspace_tags(owner_user_id, project_id, &env.name);
|
||||
work.put_project_secret(&name, &env.kms_key_arn, &repaired, &tags).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// How long a fresh access point may take to become `available`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PollTiming {
|
||||
pub interval: Duration,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for PollTiming {
|
||||
fn default() -> Self {
|
||||
PollTiming {
|
||||
interval: Duration::from_secs(2),
|
||||
timeout: Duration::from_secs(90),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until the access point is `available` (a task mounting one that is
|
||||
/// still `creating` fails to start).
|
||||
pub async fn wait_access_point<R: RuntimeApi>(
|
||||
rt: &R,
|
||||
id: &str,
|
||||
timing: PollTiming,
|
||||
) -> Result<(), AwsError> {
|
||||
let deadline = tokio::time::Instant::now() + timing.timeout;
|
||||
loop {
|
||||
match rt.access_point_state(id).await?.as_deref() {
|
||||
Some("available") => return Ok(()),
|
||||
Some("creating") | Some("updating") => {}
|
||||
Some(s) => {
|
||||
return Err(other(format!(
|
||||
"The project's EFS access point {id} is {s}; start the project again"
|
||||
)))
|
||||
}
|
||||
None => {
|
||||
return Err(other(format!(
|
||||
"The project's EFS access point {id} disappeared; start the project again"
|
||||
)))
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() + timing.interval > deadline {
|
||||
return Err(other(format!(
|
||||
"The project's EFS access point {id} did not become available in time; \
|
||||
start the project again in a minute"
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(timing.interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ARNs
|
||||
|
||||
/// The cluster *name* of `arn:...:task/<cluster>/<id>` (ECS accepts a name
|
||||
/// wherever it takes a cluster).
|
||||
pub fn cluster_of_task(task_arn: &str) -> Option<&str> {
|
||||
let (_, rest) = task_arn.split_once(":task/")?;
|
||||
let (cluster, id) = rest.split_once('/')?;
|
||||
(task_arn.starts_with("arn:") && !cluster.is_empty() && !id.is_empty() && !id.contains('/'))
|
||||
.then_some(cluster)
|
||||
}
|
||||
|
||||
/// The cluster ARN of a task ARN (same partition, region and account).
|
||||
pub fn cluster_arn_of_task(task_arn: &str) -> Option<String> {
|
||||
let cluster = cluster_of_task(task_arn)?;
|
||||
let (prefix, _) = task_arn.split_once(":task/")?;
|
||||
Some(format!("{prefix}:cluster/{cluster}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- removal
|
||||
|
||||
/// What removing a project's AWS resources needs to know.
|
||||
pub struct RemoveRequest<'a> {
|
||||
pub env: &'a str,
|
||||
pub region: &'a str,
|
||||
pub owner_user_id: &'a str,
|
||||
pub owner_key: &'a str,
|
||||
pub project_id: &'a str,
|
||||
/// The task ARN the store has, if any (stopped too, in case the listing
|
||||
/// missed it).
|
||||
pub stored_task_arn: Option<&'a str>,
|
||||
pub stored_access_point_id: Option<&'a str>,
|
||||
/// Delete the EFS access point (ruling R9). The files stay on the
|
||||
/// user's own file system (only their task role can read it) until
|
||||
/// offboarding destroys it; a later start of the same project gets a new
|
||||
/// access point onto the same directory.
|
||||
pub discard_data: bool,
|
||||
}
|
||||
|
||||
/// Stop the project's task, delete its secret (no recovery window, R10) and,
|
||||
/// with `discard_data`, delete its access point (not the files, R9). Task definition revisions
|
||||
/// are left registered (R8). A task that can't be stopped stops the removal (nothing
|
||||
/// billed may be left running); the rest is attempted in full and every
|
||||
/// failure reported.
|
||||
pub async fn remove_workspace_resources<T: TaskApi, W: WorkspaceApi, R: RuntimeApi>(
|
||||
tasks: &T,
|
||||
work: &W,
|
||||
rt: &R,
|
||||
req: &RemoveRequest<'_>,
|
||||
) -> Result<(), AwsError> {
|
||||
let cluster = environment_cluster_name(req.env);
|
||||
let family = task_family(req.env, req.owner_key, req.project_id);
|
||||
|
||||
// 1. The task.
|
||||
let live =
|
||||
find_project_task(tasks, &cluster, &family, req.owner_user_id, req.project_id).await?;
|
||||
let mut to_stop: Vec<String> = live.into_iter().map(|t| t.arn).collect();
|
||||
if let Some(arn) = req.stored_task_arn {
|
||||
if cluster_of_task(arn) == Some(cluster.as_str()) && !to_stop.iter().any(|a| a == arn) {
|
||||
to_stop.push(arn.to_string());
|
||||
}
|
||||
}
|
||||
for arn in &to_stop {
|
||||
tasks.stop_task(&cluster, arn, STOP_REASON).await?;
|
||||
}
|
||||
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
// 2. Task definition revisions are left ACTIVE (ruling R8): ECS
|
||||
// authorizes `DeregisterTaskDefinition` against `*` only, with no tag
|
||||
// condition, so the workspace tier can't retire just its own. They are
|
||||
// inert (no secret values, only the secret's ARN) and free, and
|
||||
// `RunOwnTasks` still requires the owner tag, so nobody else can run
|
||||
// them. An admin can deregister them (`aws/README.md`).
|
||||
log::info!(
|
||||
"Project {}: its task definition revisions ({family}:*) are left registered; an \
|
||||
administrator can deregister them",
|
||||
req.project_id
|
||||
);
|
||||
|
||||
// 3. The secret.
|
||||
let secret = project_secret_name(req.env, req.owner_key, req.project_id);
|
||||
if let Err(e) = rt.delete_secret(&secret).await {
|
||||
failures.push(format!("secret {secret}: {e}"));
|
||||
}
|
||||
|
||||
// 4. The access point, only when asked.
|
||||
if req.discard_data {
|
||||
let mut ids: Vec<String> = req.stored_access_point_id.map(String::from).into_iter().collect();
|
||||
let token = user_file_system_token(req.env, req.owner_key);
|
||||
match work.find_file_system(&token).await {
|
||||
Ok(Some(fs)) if fs.owner.as_deref() == Some(req.owner_user_id) => {
|
||||
let root = access_point_root(req.owner_key, req.project_id);
|
||||
match work.access_points(&fs.id).await {
|
||||
Ok(points) => ids.extend(
|
||||
points
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
a.file_system_id == fs.id
|
||||
&& a.root_path.as_deref() == Some(root.as_str())
|
||||
&& a.owner.as_deref() == Some(req.owner_user_id)
|
||||
&& a.project.as_deref() == Some(req.project_id)
|
||||
})
|
||||
.map(|a| a.id),
|
||||
),
|
||||
Err(e) => failures.push(format!("access points: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => failures.push(format!("file system: {e}")),
|
||||
}
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
for id in ids {
|
||||
if let Err(e) = rt.delete_access_point(&id).await {
|
||||
failures.push(format!("access point {id}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(other(format!(
|
||||
"Some of the project's AWS resources in {} could not be removed: {}",
|
||||
req.region,
|
||||
failures.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- real API
|
||||
|
||||
pub struct SdkRuntimeApi {
|
||||
ssm: aws_sdk_ssm::Client,
|
||||
iam: aws_sdk_iam::Client,
|
||||
secrets: aws_sdk_secretsmanager::Client,
|
||||
efs: aws_sdk_efs::Client,
|
||||
}
|
||||
|
||||
impl SdkRuntimeApi {
|
||||
pub fn new(cfg: &SdkConfig) -> Self {
|
||||
SdkRuntimeApi {
|
||||
ssm: aws_sdk_ssm::Client::new(cfg),
|
||||
iam: aws_sdk_iam::Client::new(cfg),
|
||||
secrets: aws_sdk_secretsmanager::Client::new(cfg),
|
||||
efs: aws_sdk_efs::Client::new(cfg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeApi for SdkRuntimeApi {
|
||||
async fn environment_record(&self, env: &str) -> Result<Option<String>, AwsError> {
|
||||
const ACTION: &str = "ssm:GetParameter";
|
||||
match self
|
||||
.ssm
|
||||
.get_parameter()
|
||||
.name(environment_parameter_name(env))
|
||||
.with_decryption(false)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(out) => Ok(out.parameter().and_then(|p| p.value()).map(str::to_string)),
|
||||
Err(e)
|
||||
if e.as_service_error()
|
||||
.is_some_and(|s| s.is_parameter_not_found()) =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(classify_sdk_error(ACTION, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn role_exists(&self, name: &str) -> Result<Knowable, AwsError> {
|
||||
let res = self.iam.get_role().role_name(name).send().await;
|
||||
knowable("iam:GetRole", res)
|
||||
}
|
||||
|
||||
async fn secret_string(&self, name: &str) -> Result<String, AwsError> {
|
||||
const ACTION: &str = "secretsmanager:GetSecretValue";
|
||||
let out = self
|
||||
.secrets
|
||||
.get_secret_value()
|
||||
.secret_id(name)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
out.secret_string()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| other(format!("{ACTION} returned no SecretString")))
|
||||
}
|
||||
|
||||
async fn access_point_state(&self, id: &str) -> Result<Option<String>, AwsError> {
|
||||
const ACTION: &str = "elasticfilesystem:DescribeAccessPoints";
|
||||
match self.efs.describe_access_points().access_point_id(id).send().await {
|
||||
Ok(out) => Ok(out
|
||||
.access_points()
|
||||
.iter()
|
||||
.find(|a| a.access_point_id() == Some(id))
|
||||
.and_then(|a| a.life_cycle_state())
|
||||
.map(|s| s.as_str().to_string())),
|
||||
Err(e)
|
||||
if e.as_service_error()
|
||||
.is_some_and(|s| s.is_access_point_not_found()) =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(classify_sdk_error(ACTION, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_secret(&self, name: &str) -> Result<(), AwsError> {
|
||||
const ACTION: &str = "secretsmanager:DeleteSecret";
|
||||
match self
|
||||
.secrets
|
||||
.delete_secret()
|
||||
.secret_id(name)
|
||||
.force_delete_without_recovery(true)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(e)
|
||||
if e.as_service_error()
|
||||
.is_some_and(|s| s.is_resource_not_found_exception()) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
// Scheduled by an older version of the app and AWS refused the
|
||||
// forced delete: nothing more this tier may do (no RestoreSecret).
|
||||
Err(e)
|
||||
if e.as_service_error().is_some_and(|s| {
|
||||
s.is_invalid_request_exception()
|
||||
&& s.message().is_some_and(|m| m.contains("scheduled for deletion"))
|
||||
}) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(classify_sdk_error(ACTION, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> {
|
||||
const ACTION: &str = "elasticfilesystem:DeleteAccessPoint";
|
||||
match self.efs.delete_access_point().access_point_id(id).send().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e)
|
||||
if e.as_service_error()
|
||||
.is_some_and(|s| s.is_access_point_not_found()) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(classify_sdk_error(ACTION, &e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,331 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput;
|
||||
use aws_sdk_ecs::operation::run_task::RunTaskInput;
|
||||
|
||||
use super::*;
|
||||
use crate::aws::tasks::TaskInfo;
|
||||
use crate::aws::tofu_modules::owner_key;
|
||||
use crate::aws::workspace::{AccessPointRef, FileSystemRef, TaskDefinitionRef};
|
||||
|
||||
const REGION: &str = "eu-west-1";
|
||||
const ACCOUNT: &str = "111122223333";
|
||||
const USER: &str = "AROAEXAMPLEID:alice@example.com";
|
||||
const PROJECT: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
|
||||
|
||||
fn ok() -> String {
|
||||
owner_key(REGION, USER)
|
||||
}
|
||||
|
||||
fn family() -> String {
|
||||
task_family("dev", &ok(), PROJECT)
|
||||
}
|
||||
|
||||
fn task_arn(id: &str) -> String {
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
calls: Mutex<Vec<String>>,
|
||||
record: Option<String>,
|
||||
role: Option<Knowable>,
|
||||
secret: Option<String>,
|
||||
ap_states: Mutex<Vec<Option<String>>>,
|
||||
live_tasks: Vec<TaskInfo>,
|
||||
revisions: Vec<TaskDefinitionRef>,
|
||||
access_points: Vec<AccessPointRef>,
|
||||
fail_secret_delete: bool,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn log(&self, s: String) {
|
||||
self.calls.lock().unwrap().push(s);
|
||||
}
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeApi for Fake {
|
||||
async fn environment_record(&self, env: &str) -> Result<Option<String>, AwsError> {
|
||||
self.log(format!("record {env}"));
|
||||
Ok(self.record.clone())
|
||||
}
|
||||
async fn role_exists(&self, name: &str) -> Result<Knowable, AwsError> {
|
||||
self.log(format!("role {name}"));
|
||||
Ok(self.role.clone().unwrap_or(Knowable::Yes))
|
||||
}
|
||||
async fn secret_string(&self, name: &str) -> Result<String, AwsError> {
|
||||
self.log(format!("secret {name}"));
|
||||
self.secret.clone().ok_or_else(|| other("no secret"))
|
||||
}
|
||||
async fn access_point_state(&self, id: &str) -> Result<Option<String>, AwsError> {
|
||||
self.log(format!("ap {id}"));
|
||||
let mut s = self.ap_states.lock().unwrap();
|
||||
Ok(if s.len() > 1 { s.remove(0) } else { s.first().cloned().flatten() })
|
||||
}
|
||||
async fn delete_secret(&self, name: &str) -> Result<(), AwsError> {
|
||||
self.log(format!("delete-secret {name}"));
|
||||
if self.fail_secret_delete {
|
||||
return Err(other("denied"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> {
|
||||
self.log(format!("delete-ap {id}"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskApi for Fake {
|
||||
async fn run_task(&self, _req: RunTaskInput) -> Result<TaskInfo, AwsError> {
|
||||
unreachable!("removal never runs a task")
|
||||
}
|
||||
async fn describe(&self, _c: &str, arns: &[String]) -> Result<Vec<TaskInfo>, AwsError> {
|
||||
Ok(self.live_tasks.iter().filter(|t| arns.contains(&t.arn)).cloned().collect())
|
||||
}
|
||||
async fn list_project_tasks(&self, _c: &str, _f: &str) -> Result<Vec<String>, AwsError> {
|
||||
Ok(self.live_tasks.iter().map(|t| t.arn.clone()).collect())
|
||||
}
|
||||
async fn stop_task(&self, cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> {
|
||||
self.log(format!("stop {cluster} {arn} {reason}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn log_tail(&self, _g: &str, _s: &str, _l: i32) -> Result<Vec<String>, AwsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceApi for Fake {
|
||||
async fn find_file_system(&self, _token: &str) -> Result<Option<FileSystemRef>, AwsError> {
|
||||
Ok(Some(FileSystemRef { id: "fs-1".into(), owner: Some(USER.into()) }))
|
||||
}
|
||||
async fn access_points(&self, _fs: &str) -> Result<Vec<AccessPointRef>, AwsError> {
|
||||
Ok(self.access_points.clone())
|
||||
}
|
||||
async fn create_access_point(
|
||||
&self,
|
||||
_fs: &str,
|
||||
_root: &str,
|
||||
_tags: &[(String, String)],
|
||||
) -> Result<String, AwsError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn put_project_secret(
|
||||
&self,
|
||||
_n: &str,
|
||||
_k: &str,
|
||||
_j: &str,
|
||||
_t: &[(String, String)],
|
||||
) -> Result<String, AwsError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn task_definition_revisions(&self, _f: &str) -> Result<Vec<String>, AwsError> {
|
||||
Ok(self.revisions.iter().map(|r| r.arn.clone()).collect())
|
||||
}
|
||||
async fn describe_task_definition(
|
||||
&self,
|
||||
arn: &str,
|
||||
) -> Result<Option<TaskDefinitionRef>, AwsError> {
|
||||
Ok(self.revisions.iter().find(|r| r.arn == arn).cloned())
|
||||
}
|
||||
async fn register_task_definition(
|
||||
&self,
|
||||
_req: RegisterTaskDefinitionInput,
|
||||
) -> Result<String, AwsError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
fn record() -> String {
|
||||
serde_json::json!({
|
||||
"schema": 1, "name": "dev", "template_version": 1,
|
||||
"cluster_arn": format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev"),
|
||||
"security_group_id": "sg-0123456789abcdef0",
|
||||
"subnet_ids": ["subnet-0a", "subnet-0b"],
|
||||
"assign_public_ip": true,
|
||||
"kms_key_arn": format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234-abcd"),
|
||||
"log_group": "/triple-c/dev", "network_mode": "public", "tenancy": "strict"
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_environment_is_read_with_one_lookup_and_validated() {
|
||||
let fake = Fake { record: Some(record()), ..Default::default() };
|
||||
let env = read_environment(&fake, "dev", ACCOUNT, REGION).await.unwrap();
|
||||
assert_eq!(env.log_group, "/triple-c/dev");
|
||||
assert_eq!(fake.calls(), vec!["record dev"]);
|
||||
|
||||
let missing = Fake::default();
|
||||
let e = read_environment(&missing, "dev", ACCOUNT, REGION).await.unwrap_err();
|
||||
assert!(e.to_string().contains("was not found"), "{e}");
|
||||
|
||||
// A record from another account is refused, by field name only.
|
||||
let foreign = Fake { record: Some(record()), ..Default::default() };
|
||||
let e = read_environment(&foreign, "dev", "999999999999", REGION).await.unwrap_err();
|
||||
assert!(e.to_string().contains("cluster_arn"), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_task_role_means_not_joined_and_a_denied_lookup_lets_the_start_try() {
|
||||
let no = Fake { role: Some(Knowable::No), ..Default::default() };
|
||||
let e = check_onboarded(&no, "dev", &ok()).await.unwrap_err();
|
||||
assert!(e.to_string().contains("not joined"), "{e}");
|
||||
assert_eq!(no.calls(), vec![format!("role triple-c-dev-{}-task", ok())]);
|
||||
let unknown = Fake { role: Some(Knowable::Unknown), ..Default::default() };
|
||||
assert!(check_onboarded(&unknown, "dev", &ok()).await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_token_is_read_from_the_secret_without_echoing_it() {
|
||||
let json = r#"{"TRIPLEC_AGENT_TOKEN":"abc-DEF_123","OTHER":"SENTINEL-VALUE-7f3a"}"#;
|
||||
assert_eq!(agent_token_from_secret(json).unwrap(), "abc-DEF_123");
|
||||
for bad in [
|
||||
r#"{"OTHER":"SENTINEL-VALUE-7f3a"}"#,
|
||||
r#"{"TRIPLEC_AGENT_TOKEN":""}"#,
|
||||
r#"{"TRIPLEC_AGENT_TOKEN":"has space"}"#,
|
||||
"SENTINEL-VALUE-7f3a",
|
||||
] {
|
||||
let e = agent_token_from_secret(bad).unwrap_err();
|
||||
assert!(!e.contains("SENTINEL") && !e.contains("space"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_token_comes_from_the_projects_own_secret() {
|
||||
let fake = Fake {
|
||||
secret: Some(r#"{"TRIPLEC_AGENT_TOKEN":"tok"}"#.into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(read_agent_token(&fake, "dev", &ok(), PROJECT).await.unwrap(), "tok");
|
||||
assert_eq!(fake.calls(), vec![format!("secret triple-c/dev/{}/{PROJECT}", ok())]);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_new_access_point_is_waited_for_until_available() {
|
||||
let fake = Fake {
|
||||
ap_states: Mutex::new(vec![Some("creating".into()), Some("creating".into()), Some("available".into())]),
|
||||
..Default::default()
|
||||
};
|
||||
wait_access_point(&fake, "fsap-1", PollTiming::default()).await.unwrap();
|
||||
assert_eq!(fake.calls().len(), 3);
|
||||
|
||||
let stuck = Fake { ap_states: Mutex::new(vec![Some("creating".into())]), ..Default::default() };
|
||||
let e = wait_access_point(&stuck, "fsap-1", PollTiming::default()).await.unwrap_err();
|
||||
assert!(e.to_string().contains("did not become available"), "{e}");
|
||||
|
||||
let gone = Fake { ap_states: Mutex::new(vec![Some("deleting".into())]), ..Default::default() };
|
||||
assert!(wait_access_point(&gone, "fsap-1", PollTiming::default()).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_arns_name_their_cluster() {
|
||||
let arn = task_arn("abc123");
|
||||
assert_eq!(cluster_of_task(&arn), Some("triple-c-dev"));
|
||||
assert_eq!(
|
||||
cluster_arn_of_task(&arn).as_deref(),
|
||||
Some(format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev").as_str())
|
||||
);
|
||||
assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/abc"), None, "old format, no cluster");
|
||||
assert_eq!(cluster_of_task("task/triple-c-dev/abc"), None);
|
||||
assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/c/a/b"), None);
|
||||
}
|
||||
|
||||
fn live(id: &str) -> TaskInfo {
|
||||
TaskInfo {
|
||||
arn: task_arn(id),
|
||||
last_status: "RUNNING".into(),
|
||||
desired_status: "RUNNING".into(),
|
||||
health: None,
|
||||
stop_code: None,
|
||||
stopped_reason: None,
|
||||
task_definition_arn: format!(
|
||||
"arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:2",
|
||||
family()
|
||||
),
|
||||
runtime_id: None,
|
||||
exec_agent_running: false,
|
||||
exit_code: None,
|
||||
owner: Some(USER.into()),
|
||||
project: Some(PROJECT.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn rev(n: u32, owner: &str) -> TaskDefinitionRef {
|
||||
TaskDefinitionRef {
|
||||
arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:{n}", family()),
|
||||
family: family(),
|
||||
owner: Some(owner.into()),
|
||||
project: Some(PROJECT.into()),
|
||||
fingerprint: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removal_stops_the_task_leaves_the_revisions_and_schedules_the_secret() {
|
||||
let fake = Fake {
|
||||
live_tasks: vec![live("t1")],
|
||||
revisions: vec![rev(2, USER), rev(3, "AROAOTHER:mallory"), rev(1, USER)],
|
||||
access_points: vec![AccessPointRef {
|
||||
id: "fsap-found".into(),
|
||||
file_system_id: "fs-1".into(),
|
||||
root_path: Some(access_point_root(&ok(), PROJECT)),
|
||||
owner: Some(USER.into()),
|
||||
project: Some(PROJECT.into()),
|
||||
state: Some("available".into()),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let key = ok();
|
||||
let t0 = task_arn("t0");
|
||||
let mut r = RemoveRequest {
|
||||
env: "dev",
|
||||
region: REGION,
|
||||
owner_user_id: USER,
|
||||
owner_key: &key,
|
||||
project_id: PROJECT,
|
||||
stored_task_arn: Some(&t0),
|
||||
stored_access_point_id: Some("fsap-stored"),
|
||||
discard_data: false,
|
||||
};
|
||||
remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap();
|
||||
let calls = fake.calls();
|
||||
let secret = project_secret_name("dev", &ok(), PROJECT);
|
||||
assert_eq!(
|
||||
calls,
|
||||
vec![
|
||||
format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t1")),
|
||||
format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t0")),
|
||||
format!("delete-secret {secret}"),
|
||||
],
|
||||
"no task definition is touched (R8) and the access point is kept"
|
||||
);
|
||||
|
||||
let keep = Fake { access_points: fake.access_points.clone(), ..Default::default() };
|
||||
r.discard_data = true;
|
||||
remove_workspace_resources(&keep, &keep, &keep, &r).await.unwrap();
|
||||
let calls = keep.calls();
|
||||
assert!(calls.contains(&"delete-ap fsap-found".to_string()), "{calls:?}");
|
||||
assert!(calls.contains(&"delete-ap fsap-stored".to_string()), "{calls:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_step_is_reported_but_the_rest_still_runs() {
|
||||
let fake = Fake { fail_secret_delete: true, ..Default::default() };
|
||||
let key = ok();
|
||||
let r = RemoveRequest {
|
||||
env: "dev",
|
||||
region: REGION,
|
||||
owner_user_id: USER,
|
||||
owner_key: &key,
|
||||
project_id: PROJECT,
|
||||
stored_task_arn: None,
|
||||
stored_access_point_id: Some("fsap-1"),
|
||||
discard_data: true,
|
||||
};
|
||||
let e = remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap_err();
|
||||
assert!(e.to_string().contains("secret"), "{e}");
|
||||
assert!(fake.calls().contains(&"delete-ap fsap-1".to_string()));
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
//! A project's ECS task: find the one already running, or run one; wait
|
||||
//! until it is ready; stop it; and read why it stopped.
|
||||
//!
|
||||
//! **A task is trusted by its tags, not its family.** Family names are built
|
||||
//! from a 12-character id prefix and any workspace user may register into any
|
||||
//! `triple-c-*` family, so a task listed under the project's family is reused
|
||||
//! only when DescribeTasks shows the caller's own `triple-c:owner` (which only
|
||||
//! the caller can set, at RunTask) and this project's `triple-c:project`.
|
||||
//!
|
||||
//! **RunTask names an exact revision.** Never a bare family: "the latest
|
||||
//! revision" of a family is whatever anyone registered last.
|
||||
//!
|
||||
//! **A quota refusal is final.** Fargate's vCPU limit (and its kin) surface as
|
||||
//! [`AwsError::QuotaExceeded`] with a message saying what to do; nothing here
|
||||
//! retries a RunTask, and the SDK's own retries are made idempotent by a
|
||||
//! client token.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use aws_sdk_ecs::operation::run_task::RunTaskInput;
|
||||
use aws_sdk_ecs::types::{
|
||||
AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, DesiredStatus, LaunchType,
|
||||
ManagedAgentName, NetworkConfiguration, Tag, Task, TaskField,
|
||||
};
|
||||
|
||||
use super::environments::EnvironmentInfo;
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
pub use super::tofu_modules::task_log_stream;
|
||||
use super::workspace::{workspace_tags, CONTAINER_NAME, TAG_OWNER, TAG_PROJECT};
|
||||
|
||||
/// The `reason` StopTask records when the app stops a task.
|
||||
pub const STOP_REASON: &str = "Stopped from Triple-C";
|
||||
/// Log lines shown when a task stops before it is ready.
|
||||
pub const LOG_TAIL_LINES: i32 = 50;
|
||||
const SPOT_PROVIDER: &str = "FARGATE_SPOT";
|
||||
/// DescribeTasks takes at most 100 tasks per call.
|
||||
const DESCRIBE_BATCH: usize = 100;
|
||||
/// ListTasks pages read per status; a project has one task, so this is slack.
|
||||
const MAX_LIST_PAGES: usize = 5;
|
||||
|
||||
fn other(message: impl Into<String>) -> AwsError {
|
||||
AwsError::Other {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app needs to know about a task.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TaskInfo {
|
||||
pub arn: String,
|
||||
/// `PROVISIONING`, `PENDING`, `ACTIVATING`, `RUNNING`, `DEACTIVATING`,
|
||||
/// `STOPPING`, `DEPROVISIONING`, `STOPPED`.
|
||||
pub last_status: String,
|
||||
pub desired_status: String,
|
||||
/// `HEALTHY`, `UNHEALTHY`, `UNKNOWN`.
|
||||
pub health: Option<String>,
|
||||
/// ECS `stopCode` (`EssentialContainerExited`, `SpotInterruption`, ...).
|
||||
pub stop_code: Option<String>,
|
||||
pub stopped_reason: Option<String>,
|
||||
pub task_definition_arn: String,
|
||||
/// The `workspace` container's runtime id (needed for an SSM target).
|
||||
pub runtime_id: Option<String>,
|
||||
/// The `workspace` container's ExecuteCommandAgent is RUNNING.
|
||||
pub exec_agent_running: bool,
|
||||
/// The `workspace` container's exit code, once it has one.
|
||||
pub exit_code: Option<i32>,
|
||||
/// Its `triple-c:owner` / `triple-c:project` tags (DescribeTasks with
|
||||
/// TAGS; RunTask's answer carries the tags it was given).
|
||||
pub owner: Option<String>,
|
||||
pub project: Option<String>,
|
||||
}
|
||||
|
||||
impl TaskInfo {
|
||||
fn is_stopping(&self) -> bool {
|
||||
self.last_status == "STOPPED" || self.desired_status == "STOPPED"
|
||||
}
|
||||
/// On its way up or up, and meant to stay that way.
|
||||
fn is_live(&self) -> bool {
|
||||
self.desired_status == "RUNNING"
|
||||
&& matches!(
|
||||
self.last_status.as_str(),
|
||||
"PROVISIONING" | "PENDING" | "ACTIVATING" | "RUNNING"
|
||||
)
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_status == "RUNNING" && self.exec_agent_running && self.runtime_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_value(tags: &[Tag], key: &str) -> Option<String> {
|
||||
tags.iter()
|
||||
.find(|t| t.key() == Some(key))
|
||||
.and_then(|t| t.value())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// An SDK task as a [`TaskInfo`]. The container facts come from the
|
||||
/// `workspace` container only.
|
||||
pub(crate) fn task_info(t: &Task) -> TaskInfo {
|
||||
let workspace = t
|
||||
.containers()
|
||||
.iter()
|
||||
.find(|c| c.name() == Some(CONTAINER_NAME));
|
||||
TaskInfo {
|
||||
arn: t.task_arn().unwrap_or_default().to_string(),
|
||||
last_status: t.last_status().unwrap_or_default().to_string(),
|
||||
desired_status: t.desired_status().unwrap_or_default().to_string(),
|
||||
health: t.health_status().map(|h| h.as_str().to_string()),
|
||||
stop_code: t.stop_code().map(|c| c.as_str().to_string()),
|
||||
stopped_reason: t.stopped_reason().map(str::to_string),
|
||||
task_definition_arn: t.task_definition_arn().unwrap_or_default().to_string(),
|
||||
runtime_id: workspace.and_then(|c| c.runtime_id()).map(str::to_string),
|
||||
exec_agent_running: workspace.is_some_and(|c| {
|
||||
c.managed_agents().iter().any(|a| {
|
||||
a.name() == Some(&ManagedAgentName::ExecuteCommandAgent)
|
||||
&& a.last_status() == Some("RUNNING")
|
||||
})
|
||||
}),
|
||||
exit_code: workspace.and_then(|c| c.exit_code()),
|
||||
owner: tag_value(t.tags(), TAG_OWNER),
|
||||
project: tag_value(t.tags(), TAG_PROJECT),
|
||||
}
|
||||
}
|
||||
|
||||
/// What running and watching tasks asks AWS. Like `WorkspaceApi`, each
|
||||
/// method returns a `Send` future and an impl may write them as `async fn`;
|
||||
/// the decisions are made by the functions below, so a fake exercises them.
|
||||
pub trait TaskApi: Send + Sync {
|
||||
/// RunTask. A quota refusal is [`AwsError::QuotaExceeded`].
|
||||
fn run_task(
|
||||
&self,
|
||||
req: RunTaskInput,
|
||||
) -> impl Future<Output = Result<TaskInfo, AwsError>> + Send;
|
||||
/// DescribeTasks (with tags). Tasks ECS no longer knows are left out.
|
||||
fn describe(
|
||||
&self,
|
||||
cluster: &str,
|
||||
arns: &[String],
|
||||
) -> impl Future<Output = Result<Vec<TaskInfo>, AwsError>> + Send;
|
||||
/// ListTasks of exactly `family`, desired status RUNNING and PENDING.
|
||||
fn list_project_tasks(
|
||||
&self,
|
||||
cluster: &str,
|
||||
family: &str,
|
||||
) -> impl Future<Output = Result<Vec<String>, AwsError>> + Send;
|
||||
fn stop_task(
|
||||
&self,
|
||||
cluster: &str,
|
||||
arn: &str,
|
||||
reason: &str,
|
||||
) -> impl Future<Output = Result<(), AwsError>> + Send;
|
||||
/// The last `limit` lines of a log stream, oldest first. A stream (or
|
||||
/// group) that does not exist is no lines, not an error.
|
||||
fn log_tail(
|
||||
&self,
|
||||
group: &str,
|
||||
stream: &str,
|
||||
limit: i32,
|
||||
) -> impl Future<Output = Result<Vec<String>, AwsError>> + Send;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- run request
|
||||
|
||||
/// The RunTask request: the exact revision, on the environment's cluster and
|
||||
/// network, with ECS Exec on and exactly the `tags` given (no propagation,
|
||||
/// no ECS-managed tags, no overrides — P1). Spot runs on the `FARGATE_SPOT`
|
||||
/// capacity provider with no launch type (the two are exclusive).
|
||||
pub fn run_task_request(
|
||||
env: &EnvironmentInfo,
|
||||
task_def_arn: &str,
|
||||
spot: bool,
|
||||
tags: &[(String, String)],
|
||||
) -> RunTaskInput {
|
||||
const BUILT: &str = "every required field is set";
|
||||
let mut req = RunTaskInput::builder()
|
||||
.cluster(&env.cluster_arn)
|
||||
.task_definition(task_def_arn)
|
||||
.count(1)
|
||||
.enable_execute_command(true)
|
||||
.network_configuration(
|
||||
NetworkConfiguration::builder()
|
||||
.awsvpc_configuration(
|
||||
AwsVpcConfiguration::builder()
|
||||
.set_subnets(Some(env.subnet_ids.clone()))
|
||||
.security_groups(&env.security_group_id)
|
||||
.assign_public_ip(if env.assign_public_ip {
|
||||
AssignPublicIp::Enabled
|
||||
} else {
|
||||
AssignPublicIp::Disabled
|
||||
})
|
||||
.build()
|
||||
.expect(BUILT),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.set_tags(Some(
|
||||
tags.iter()
|
||||
.map(|(k, v)| Tag::builder().key(k).value(v).build())
|
||||
.collect(),
|
||||
))
|
||||
// SDK retries of this call must not start a second task.
|
||||
.client_token(uuid::Uuid::new_v4().to_string());
|
||||
req = if spot {
|
||||
req.capacity_provider_strategy(
|
||||
CapacityProviderStrategyItem::builder()
|
||||
.capacity_provider(SPOT_PROVIDER)
|
||||
.weight(1)
|
||||
.build()
|
||||
.expect(BUILT),
|
||||
)
|
||||
} else {
|
||||
req.launch_type(LaunchType::Fargate)
|
||||
};
|
||||
req.build().expect(BUILT)
|
||||
}
|
||||
|
||||
/// The family of `arn:...:task-definition/<family>:<revision>`, when the
|
||||
/// revision is a number. `None` for a bare family or anything else.
|
||||
pub fn revision_family(arn: &str) -> Option<&str> {
|
||||
if !arn.starts_with("arn:") {
|
||||
return None;
|
||||
}
|
||||
let (_, rest) = arn.rsplit_once(":task-definition/")?;
|
||||
let (family, rev) = rest.rsplit_once(':')?;
|
||||
(!family.is_empty()
|
||||
&& !family.contains('/')
|
||||
&& !rev.is_empty()
|
||||
&& rev.bytes().all(|c| c.is_ascii_digit()))
|
||||
.then_some(family)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- find or run
|
||||
|
||||
/// The project's live task (provisioning through running, not being
|
||||
/// stopped), owned by the caller and tagged with this project. A RUNNING
|
||||
/// one is preferred.
|
||||
pub async fn find_project_task<A: TaskApi>(
|
||||
api: &A,
|
||||
cluster: &str,
|
||||
family: &str,
|
||||
owner_user_id: &str,
|
||||
project_id: &str,
|
||||
) -> Result<Option<TaskInfo>, AwsError> {
|
||||
let arns = api.list_project_tasks(cluster, family).await?;
|
||||
if arns.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut mine: Vec<TaskInfo> = Vec::new();
|
||||
for chunk in arns.chunks(DESCRIBE_BATCH) {
|
||||
mine.extend(api.describe(cluster, chunk).await?.into_iter().filter(|t| {
|
||||
t.owner.as_deref() == Some(owner_user_id)
|
||||
&& t.project.as_deref() == Some(project_id)
|
||||
&& revision_family(&t.task_definition_arn) == Some(family)
|
||||
&& t.is_live()
|
||||
}));
|
||||
}
|
||||
mine.sort_by_key(|t| t.last_status != "RUNNING");
|
||||
Ok(mine.into_iter().next())
|
||||
}
|
||||
|
||||
/// What [`find_or_run`] did.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TaskStart {
|
||||
pub task: TaskInfo,
|
||||
/// An existing task was found (nothing was run). Its agent token is the
|
||||
/// one it started with — see [`find_project_task`] in the report for
|
||||
/// why a caller should look before rotating the token.
|
||||
pub reused: bool,
|
||||
}
|
||||
|
||||
/// The project's live task if there is one (Review Focus 2: never a second
|
||||
/// task for a project), else RunTask with the exact `task_def_arn`.
|
||||
pub async fn find_or_run<A: TaskApi>(
|
||||
api: &A,
|
||||
env: &EnvironmentInfo,
|
||||
owner_user_id: &str,
|
||||
project_id: &str,
|
||||
task_def_arn: &str,
|
||||
spot: bool,
|
||||
) -> Result<TaskStart, AwsError> {
|
||||
let family = revision_family(task_def_arn).ok_or_else(|| {
|
||||
other(format!(
|
||||
"\"{task_def_arn}\" is not an exact task definition revision"
|
||||
))
|
||||
})?;
|
||||
if !family.starts_with("triple-c-") {
|
||||
return Err(other(format!(
|
||||
"\"{task_def_arn}\" is not a Triple-C task definition"
|
||||
)));
|
||||
}
|
||||
if let Some(task) =
|
||||
find_project_task(api, &env.cluster_arn, family, owner_user_id, project_id).await?
|
||||
{
|
||||
return Ok(TaskStart { task, reused: true });
|
||||
}
|
||||
let tags = workspace_tags(owner_user_id, project_id, &env.name);
|
||||
let task = api
|
||||
.run_task(run_task_request(env, task_def_arn, spot, &tags))
|
||||
.await?;
|
||||
Ok(TaskStart {
|
||||
task,
|
||||
reused: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ quotas
|
||||
|
||||
/// Whether an AWS message says an account limit or quota was hit.
|
||||
pub fn looks_like_quota(text: &str) -> bool {
|
||||
let t = text.to_lowercase();
|
||||
[
|
||||
"you've reached the limit",
|
||||
"you\u{2019}ve reached the limit",
|
||||
"limitexceeded",
|
||||
"limit exceeded",
|
||||
"quota",
|
||||
]
|
||||
.iter()
|
||||
.any(|s| t.contains(s))
|
||||
}
|
||||
|
||||
/// The error for a RunTask refused on a quota.
|
||||
pub fn quota_error(detail: &str) -> AwsError {
|
||||
AwsError::QuotaExceeded {
|
||||
message: format!(
|
||||
"AWS would not start the task because an account limit was reached: {detail}. \
|
||||
Stop another workspace, or ask an administrator to raise the Fargate quota \
|
||||
(Service Quotas, AWS Fargate) for this region. Triple-C does not retry."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `e` as a quota error when its message is one; otherwise unchanged.
|
||||
pub fn quota_or(e: AwsError) -> AwsError {
|
||||
match e {
|
||||
AwsError::Other { ref message } if looks_like_quota(message) => quota_error(message),
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// RunTask's answer: its one task, or an error from its `failures`
|
||||
/// (`(reason, detail)`), a quota one when they say so.
|
||||
pub fn run_task_outcome(
|
||||
tasks: Vec<TaskInfo>,
|
||||
failures: Vec<(Option<String>, Option<String>)>,
|
||||
) -> Result<TaskInfo, AwsError> {
|
||||
if let Some(task) = tasks.into_iter().find(|t| !t.arn.is_empty()) {
|
||||
return Ok(task);
|
||||
}
|
||||
let text = failures
|
||||
.iter()
|
||||
.map(|(r, d)| match (r, d) {
|
||||
(Some(r), Some(d)) => format!("{r} ({d})"),
|
||||
(Some(r), None) => r.clone(),
|
||||
(None, Some(d)) => d.clone(),
|
||||
(None, None) => "unknown failure".into(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
if text.is_empty() {
|
||||
return Err(other("ecs:RunTask returned no task and no failure"));
|
||||
}
|
||||
if looks_like_quota(&text) {
|
||||
return Err(quota_error(&text));
|
||||
}
|
||||
Err(other(format!("ECS could not start the task: {text}")))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- wait
|
||||
|
||||
/// How [`wait_ready`] polls. Injectable so tests don't wait.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WaitTiming {
|
||||
pub interval: Duration,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for WaitTiming {
|
||||
fn default() -> Self {
|
||||
WaitTiming {
|
||||
interval: Duration::from_secs(5),
|
||||
timeout: Duration::from_secs(6 * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a project's task logs go: the environment's log group and the
|
||||
/// project's `awslogs-stream-prefix` (Task 4's `log_stream_prefix`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TaskLogs {
|
||||
pub group: String,
|
||||
pub stream_prefix: String,
|
||||
}
|
||||
|
||||
fn describe_timeout(t: Duration) -> String {
|
||||
let secs = t.as_secs();
|
||||
if secs >= 60 && secs % 60 == 0 {
|
||||
let m = secs / 60;
|
||||
format!("{m} minute{}", if m == 1 { "" } else { "s" })
|
||||
} else {
|
||||
format!("{secs} seconds")
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll until the task is RUNNING with its exec agent up and a runtime id
|
||||
/// (what an SSM session needs), calling `progress` with each look. A task
|
||||
/// that stops (or is being stopped) fails with its reason and the last
|
||||
/// [`LOG_TAIL_LINES`] lines of its log; one that never gets there fails
|
||||
/// after `timing.timeout` with its last status. The task is left as it is
|
||||
/// either way: stopping it is the caller's call.
|
||||
pub async fn wait_ready<A: TaskApi>(
|
||||
api: &A,
|
||||
cluster: &str,
|
||||
arn: &str,
|
||||
logs: &TaskLogs,
|
||||
timing: WaitTiming,
|
||||
progress: impl Fn(&TaskInfo),
|
||||
) -> Result<TaskInfo, AwsError> {
|
||||
let deadline = tokio::time::Instant::now() + timing.timeout;
|
||||
let wanted = [arn.to_string()];
|
||||
loop {
|
||||
let Some(t) = api
|
||||
.describe(cluster, &wanted)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|t| t.arn == arn)
|
||||
else {
|
||||
return Err(other(format!(
|
||||
"The task {arn} is no longer known to ECS; start the project again"
|
||||
)));
|
||||
};
|
||||
progress(&t);
|
||||
if t.is_stopping() {
|
||||
return Err(stopped_error(api, logs, &t).await);
|
||||
}
|
||||
if t.is_ready() {
|
||||
return Ok(t);
|
||||
}
|
||||
if tokio::time::Instant::now() + timing.interval > deadline {
|
||||
return Err(other(format!(
|
||||
"The task did not become ready within {} (last status {}{}). It may still \
|
||||
be starting; check the ECS console, or stop the project and start it again.",
|
||||
describe_timeout(timing.timeout),
|
||||
t.last_status,
|
||||
if t.last_status == "RUNNING" && !t.exec_agent_running {
|
||||
", ECS Exec agent not running yet"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(timing.interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn stopped_error<A: TaskApi>(api: &A, logs: &TaskLogs, t: &TaskInfo) -> AwsError {
|
||||
let reason = t
|
||||
.stopped_reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "no reason given".into());
|
||||
let code = t
|
||||
.stop_code
|
||||
.as_deref()
|
||||
.map(|c| format!(" ({c})"))
|
||||
.unwrap_or_default();
|
||||
let stream = task_log_stream(&logs.stream_prefix, &t.arn);
|
||||
let tail = match api.log_tail(&logs.group, &stream, LOG_TAIL_LINES).await {
|
||||
Ok(lines) if lines.is_empty() => "\n(The task wrote no log lines.)".to_string(),
|
||||
Ok(lines) => format!("\nLast log lines:\n{}", lines.join("\n")),
|
||||
Err(e) => format!("\n(Its log could not be read: {e})"),
|
||||
};
|
||||
other(format!(
|
||||
"The task stopped before it was ready: {reason}{code}.{tail}"
|
||||
))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- stop / watch
|
||||
|
||||
/// The entrypoint's exit status after an idle stop (`container/entrypoint.sh`,
|
||||
/// `stop_keepalive`). A stop from ECS (SIGTERM) exits 0; this one is distinct
|
||||
/// so an idle stop (`EssentialContainerExited`) is not mistaken for a crash.
|
||||
pub const IDLE_EXIT_CODE: i32 = 75;
|
||||
|
||||
/// Why a task stopped, as the app reports it (spec, "Lifecycle and status").
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StopKind {
|
||||
/// Exited on its own (the essential container ended, or AWS retired it),
|
||||
/// with ECS's stopped reason when it gave one.
|
||||
Exited(Option<String>),
|
||||
/// The workspace's own idle stop ([`IDLE_EXIT_CODE`]).
|
||||
Idle,
|
||||
/// A Fargate Spot interruption (P8).
|
||||
Spot,
|
||||
/// Never got going: the project goes to Error with this reason.
|
||||
FailedToStart(String),
|
||||
/// Stopped by someone (the app, the console, the CLI).
|
||||
UserInitiated,
|
||||
}
|
||||
|
||||
pub fn classify_stop(info: &TaskInfo) -> StopKind {
|
||||
match info.stop_code.as_deref() {
|
||||
Some("SpotInterruption") => StopKind::Spot,
|
||||
Some("TaskFailedToStart") => StopKind::FailedToStart(
|
||||
info.stopped_reason
|
||||
.clone()
|
||||
.filter(|r| !r.trim().is_empty())
|
||||
.unwrap_or_else(|| "The task failed to start".into()),
|
||||
),
|
||||
Some("UserInitiated") => StopKind::UserInitiated,
|
||||
Some("EssentialContainerExited") if info.exit_code == Some(IDLE_EXIT_CODE) => StopKind::Idle,
|
||||
_ => StopKind::Exited(info.stopped_reason.clone().filter(|r| !r.trim().is_empty())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Of `arns`, the ones that are STOPPED or that ECS no longer knows (it
|
||||
/// forgets stopped tasks after about an hour), each with why it stopped (a
|
||||
/// forgotten task: [`StopKind::Exited`] without a reason). A task merely
|
||||
/// being stopped is not yet reported. For the AWS death watch.
|
||||
pub async fn stopped_among<A: TaskApi>(
|
||||
api: &A,
|
||||
cluster: &str,
|
||||
arns: &[String],
|
||||
) -> Result<Vec<(String, StopKind)>, AwsError> {
|
||||
let mut stopped = Vec::new();
|
||||
for chunk in arns.chunks(DESCRIBE_BATCH) {
|
||||
let known = api.describe(cluster, chunk).await?;
|
||||
for arn in chunk {
|
||||
match known.iter().find(|t| &t.arn == arn) {
|
||||
Some(t) if t.last_status != "STOPPED" => {}
|
||||
Some(t) => stopped.push((arn.clone(), classify_stop(t))),
|
||||
None => stopped.push((arn.clone(), StopKind::Exited(None))),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(stopped)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- real API
|
||||
|
||||
/// The real thing, from a connection's `SdkConfig`.
|
||||
pub struct SdkTaskApi {
|
||||
ecs: aws_sdk_ecs::Client,
|
||||
logs: aws_sdk_cloudwatchlogs::Client,
|
||||
}
|
||||
|
||||
impl SdkTaskApi {
|
||||
pub fn new(cfg: &SdkConfig) -> Self {
|
||||
SdkTaskApi {
|
||||
ecs: aws_sdk_ecs::Client::new(cfg),
|
||||
logs: aws_sdk_cloudwatchlogs::Client::new(cfg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskApi for SdkTaskApi {
|
||||
async fn run_task(&self, req: RunTaskInput) -> Result<TaskInfo, AwsError> {
|
||||
const ACTION: &str = "ecs:RunTask";
|
||||
// The fluent builder takes no whole input; copy every field.
|
||||
let out = self
|
||||
.ecs
|
||||
.run_task()
|
||||
.set_capacity_provider_strategy(req.capacity_provider_strategy)
|
||||
.set_cluster(req.cluster)
|
||||
.set_count(req.count)
|
||||
.set_enable_ecs_managed_tags(req.enable_ecs_managed_tags)
|
||||
.set_enable_execute_command(req.enable_execute_command)
|
||||
.set_group(req.group)
|
||||
.set_launch_type(req.launch_type)
|
||||
.set_network_configuration(req.network_configuration)
|
||||
.set_overrides(req.overrides)
|
||||
.set_placement_constraints(req.placement_constraints)
|
||||
.set_placement_strategy(req.placement_strategy)
|
||||
.set_platform_version(req.platform_version)
|
||||
.set_propagate_tags(req.propagate_tags)
|
||||
.set_reference_id(req.reference_id)
|
||||
.set_started_by(req.started_by)
|
||||
.set_tags(req.tags)
|
||||
.set_task_definition(req.task_definition)
|
||||
.set_client_token(req.client_token)
|
||||
.set_volume_configurations(req.volume_configurations)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| quota_or(classify_sdk_error(ACTION, &e)))?;
|
||||
run_task_outcome(
|
||||
out.tasks().iter().map(task_info).collect(),
|
||||
out.failures()
|
||||
.iter()
|
||||
.map(|f| (f.reason().map(String::from), f.detail().map(String::from)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn describe(&self, cluster: &str, arns: &[String]) -> Result<Vec<TaskInfo>, AwsError> {
|
||||
const ACTION: &str = "ecs:DescribeTasks";
|
||||
let mut out = Vec::new();
|
||||
for chunk in arns.chunks(DESCRIBE_BATCH) {
|
||||
let page = self
|
||||
.ecs
|
||||
.describe_tasks()
|
||||
.cluster(cluster)
|
||||
.set_tasks(Some(chunk.to_vec()))
|
||||
.include(TaskField::Tags)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
out.extend(page.tasks().iter().map(task_info));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_project_tasks(
|
||||
&self,
|
||||
cluster: &str,
|
||||
family: &str,
|
||||
) -> Result<Vec<String>, AwsError> {
|
||||
const ACTION: &str = "ecs:ListTasks";
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
for status in [DesiredStatus::Running, DesiredStatus::Pending] {
|
||||
let mut token: Option<String> = None;
|
||||
for _ in 0..MAX_LIST_PAGES {
|
||||
let page = self
|
||||
.ecs
|
||||
.list_tasks()
|
||||
.cluster(cluster)
|
||||
.family(family)
|
||||
.desired_status(status.clone())
|
||||
.set_next_token(token.take())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
for a in page.task_arns() {
|
||||
if !found.contains(a) {
|
||||
found.push(a.clone());
|
||||
}
|
||||
}
|
||||
match page.next_token() {
|
||||
Some(t) => token = Some(t.to_string()),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
async fn stop_task(&self, cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> {
|
||||
const ACTION: &str = "ecs:StopTask";
|
||||
self.ecs
|
||||
.stop_task()
|
||||
.cluster(cluster)
|
||||
.task(arn)
|
||||
.reason(reason)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| classify_sdk_error(ACTION, &e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn log_tail(
|
||||
&self,
|
||||
group: &str,
|
||||
stream: &str,
|
||||
limit: i32,
|
||||
) -> Result<Vec<String>, AwsError> {
|
||||
const ACTION: &str = "logs:GetLogEvents";
|
||||
let out = self
|
||||
.logs
|
||||
.get_log_events()
|
||||
.log_group_name(group)
|
||||
.log_stream_name(stream)
|
||||
.start_from_head(false)
|
||||
.limit(limit)
|
||||
.send()
|
||||
.await;
|
||||
match out {
|
||||
Ok(page) => Ok(page
|
||||
.events()
|
||||
.iter()
|
||||
.filter_map(|e| e.message())
|
||||
.map(str::to_string)
|
||||
.collect()),
|
||||
Err(e)
|
||||
if e.as_service_error()
|
||||
.is_some_and(|s| s.is_resource_not_found_exception()) =>
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(e) => Err(classify_sdk_error(ACTION, &e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,744 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_sdk_ecs::types::{
|
||||
AssignPublicIp, Container, LaunchType, ManagedAgent, ManagedAgentName, Tag, Task, TaskStopCode,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::aws::environments::EnvironmentInfo;
|
||||
use crate::aws::tofu_modules::{log_stream_prefix, owner_key};
|
||||
|
||||
const ACCOUNT: &str = "111122223333";
|
||||
const REGION: &str = "eu-west-1";
|
||||
const USER: &str = "AROAEXAMPLEID:alice@example.com";
|
||||
const OTHER: &str = "AROAOTHERID:mallory";
|
||||
const PROJECT_ID: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
|
||||
const FAMILY: &str = "triple-c-dev-abcdef012345-0f1e2d3c-4b5";
|
||||
|
||||
fn cluster() -> String {
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev")
|
||||
}
|
||||
|
||||
fn td_arn(rev: u32) -> String {
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}:{rev}")
|
||||
}
|
||||
|
||||
fn task_arn(id: &str) -> String {
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}")
|
||||
}
|
||||
|
||||
fn env_info() -> EnvironmentInfo {
|
||||
EnvironmentInfo {
|
||||
schema: 1,
|
||||
name: "dev".into(),
|
||||
template_version: 1,
|
||||
cluster_arn: cluster(),
|
||||
security_group_id: "sg-0123456789abcdef0".into(),
|
||||
subnet_ids: vec!["subnet-0a".into(), "subnet-0b".into()],
|
||||
assign_public_ip: true,
|
||||
kms_key_arn: format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234"),
|
||||
log_group: "/triple-c/dev".into(),
|
||||
network_mode: "public".into(),
|
||||
tenancy: "strict".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_quota_error(e: &AwsError) -> bool {
|
||||
matches!(e, AwsError::QuotaExceeded { .. })
|
||||
}
|
||||
|
||||
fn info(id: &str, last: &str) -> TaskInfo {
|
||||
TaskInfo {
|
||||
arn: task_arn(id),
|
||||
last_status: last.into(),
|
||||
desired_status: if last == "STOPPED" {
|
||||
"STOPPED"
|
||||
} else {
|
||||
"RUNNING"
|
||||
}
|
||||
.into(),
|
||||
health: None,
|
||||
stop_code: None,
|
||||
stopped_reason: None,
|
||||
task_definition_arn: td_arn(3),
|
||||
runtime_id: None,
|
||||
exec_agent_running: false,
|
||||
exit_code: None,
|
||||
owner: Some(USER.into()),
|
||||
project: Some(PROJECT_ID.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(id: &str) -> TaskInfo {
|
||||
TaskInfo {
|
||||
runtime_id: Some(format!("{id}-123")),
|
||||
exec_agent_running: true,
|
||||
..info(id, "RUNNING")
|
||||
}
|
||||
}
|
||||
|
||||
/// A scripted `TaskApi`: `listed` answers ListTasks, `described` answers
|
||||
/// every DescribeTasks in turn (the last one repeats), `run` answers RunTask.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
listed: Vec<String>,
|
||||
described: Mutex<Vec<Vec<TaskInfo>>>,
|
||||
run: Mutex<Option<Result<TaskInfo, AwsError>>>,
|
||||
logs: Vec<String>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
run_requests: Mutex<Vec<RunTaskInput>>,
|
||||
log_requests: Mutex<Vec<(String, String, i32)>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
fn describing(self, answers: Vec<Vec<TaskInfo>>) -> Self {
|
||||
*self.described.lock().unwrap() = answers;
|
||||
self
|
||||
}
|
||||
fn running(self, r: Result<TaskInfo, AwsError>) -> Self {
|
||||
*self.run.lock().unwrap() = Some(r);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskApi for Fake {
|
||||
async fn run_task(&self, req: RunTaskInput) -> Result<TaskInfo, AwsError> {
|
||||
self.calls.lock().unwrap().push("run".into());
|
||||
self.run_requests.lock().unwrap().push(req);
|
||||
self.run
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| Ok(info("new", "PROVISIONING")))
|
||||
}
|
||||
async fn describe(&self, cluster: &str, arns: &[String]) -> Result<Vec<TaskInfo>, AwsError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("describe {cluster} {}", arns.join(",")));
|
||||
let mut d = self.described.lock().unwrap();
|
||||
let answer = if d.len() > 1 {
|
||||
d.remove(0)
|
||||
} else {
|
||||
d.first().cloned().unwrap_or_default()
|
||||
};
|
||||
Ok(answer
|
||||
.into_iter()
|
||||
.filter(|t| arns.contains(&t.arn))
|
||||
.collect())
|
||||
}
|
||||
async fn list_project_tasks(
|
||||
&self,
|
||||
cluster: &str,
|
||||
family: &str,
|
||||
) -> Result<Vec<String>, AwsError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("list {cluster} {family}"));
|
||||
Ok(self.listed.clone())
|
||||
}
|
||||
async fn stop_task(&self, _cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("stop {arn} {reason}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn log_tail(
|
||||
&self,
|
||||
group: &str,
|
||||
stream: &str,
|
||||
limit: i32,
|
||||
) -> Result<Vec<String>, AwsError> {
|
||||
self.log_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((group.into(), stream.into(), limit));
|
||||
Ok(self.logs.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn tags() -> Vec<(String, String)> {
|
||||
crate::aws::workspace::workspace_tags(USER, PROJECT_ID, "dev")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ run request
|
||||
|
||||
#[test]
|
||||
fn on_demand_runs_fargate_on_the_environment_network_with_exec_and_the_three_tags() {
|
||||
let req = run_task_request(&env_info(), &td_arn(3), false, &tags());
|
||||
assert_eq!(req.cluster(), Some(cluster().as_str()));
|
||||
assert_eq!(req.task_definition(), Some(td_arn(3).as_str()));
|
||||
assert_eq!(req.launch_type(), Some(&LaunchType::Fargate));
|
||||
assert!(req.capacity_provider_strategy().is_empty());
|
||||
assert_eq!(req.enable_execute_command(), Some(true));
|
||||
assert_eq!(req.count(), Some(1));
|
||||
let vpc = req
|
||||
.network_configuration()
|
||||
.and_then(|n| n.awsvpc_configuration())
|
||||
.expect("awsvpc");
|
||||
assert_eq!(vpc.subnets(), ["subnet-0a", "subnet-0b"]);
|
||||
assert_eq!(vpc.security_groups(), ["sg-0123456789abcdef0"]);
|
||||
assert_eq!(vpc.assign_public_ip(), Some(&AssignPublicIp::Enabled));
|
||||
let got: Vec<(String, String)> = req
|
||||
.tags()
|
||||
.iter()
|
||||
.map(|t| (t.key().unwrap().to_string(), t.value().unwrap().to_string()))
|
||||
.collect();
|
||||
assert_eq!(got, tags());
|
||||
// Nothing project-shaped rides along (P1), and tags are given, not propagated.
|
||||
assert!(req.overrides().is_none());
|
||||
assert!(req.propagate_tags().is_none());
|
||||
assert!(req.enable_ecs_managed_tags().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_uses_the_fargate_spot_capacity_provider_and_no_launch_type() {
|
||||
let req = run_task_request(&env_info(), &td_arn(3), true, &tags());
|
||||
assert_eq!(req.launch_type(), None);
|
||||
let strategy = req.capacity_provider_strategy();
|
||||
assert_eq!(strategy.len(), 1);
|
||||
assert_eq!(strategy[0].capacity_provider(), "FARGATE_SPOT");
|
||||
assert_eq!(strategy[0].weight(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_environment_gets_no_public_ip() {
|
||||
let mut env = env_info();
|
||||
env.assign_public_ip = false;
|
||||
let req = run_task_request(&env, &td_arn(3), false, &tags());
|
||||
let vpc = req
|
||||
.network_configuration()
|
||||
.unwrap()
|
||||
.awsvpc_configuration()
|
||||
.unwrap();
|
||||
assert_eq!(vpc.assign_public_ip(), Some(&AssignPublicIp::Disabled));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ find or run
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_existing_running_task_is_reused_and_nothing_is_run() {
|
||||
let api = Fake {
|
||||
listed: vec![task_arn("t1")],
|
||||
..Default::default()
|
||||
}
|
||||
.describing(vec![vec![ready("t1")]]);
|
||||
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(4), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(got.reused);
|
||||
assert_eq!(got.task.arn, task_arn("t1"));
|
||||
assert!(!api.calls().contains(&"run".to_string()));
|
||||
assert_eq!(api.calls()[0], format!("list {} {FAMILY}", cluster()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pending_task_is_reused_too() {
|
||||
let api = Fake {
|
||||
listed: vec![task_arn("t1")],
|
||||
..Default::default()
|
||||
}
|
||||
.describing(vec![vec![info("t1", "PENDING")]]);
|
||||
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(got.reused);
|
||||
assert_eq!(got.task.last_status, "PENDING");
|
||||
assert!(!api.calls().contains(&"run".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_task_of_another_owner_or_project_in_the_family_is_never_reused() {
|
||||
let mut foreign = info("theirs", "RUNNING");
|
||||
foreign.owner = Some(OTHER.into());
|
||||
let mut other_project = info("other-project", "RUNNING");
|
||||
other_project.project = Some("ffffffff-0000-0000-0000-000000000000".into());
|
||||
let mut untagged = info("untagged", "RUNNING");
|
||||
untagged.owner = None;
|
||||
untagged.project = None;
|
||||
let api = Fake {
|
||||
listed: vec![
|
||||
task_arn("theirs"),
|
||||
task_arn("other-project"),
|
||||
task_arn("untagged"),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
.describing(vec![vec![foreign, other_project, untagged]]);
|
||||
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!got.reused);
|
||||
assert_eq!(got.task.arn, task_arn("new"));
|
||||
assert_eq!(api.calls().last().unwrap(), "run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_task_already_stopping_is_not_reused() {
|
||||
let mut stopping = info("t1", "RUNNING");
|
||||
stopping.desired_status = "STOPPED".into();
|
||||
let api = Fake {
|
||||
listed: vec![task_arn("t1"), task_arn("t2")],
|
||||
..Default::default()
|
||||
}
|
||||
.describing(vec![vec![stopping, info("t2", "DEPROVISIONING")]]);
|
||||
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!got.reused);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_running_task_is_preferred_over_a_pending_one() {
|
||||
let api = Fake {
|
||||
listed: vec![task_arn("p"), task_arn("r")],
|
||||
..Default::default()
|
||||
}
|
||||
.describing(vec![vec![info("p", "PENDING"), ready("r")]]);
|
||||
let got = find_project_task(&api, &cluster(), FAMILY, USER, PROJECT_ID)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(got.arn, task_arn("r"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_listed_means_no_describe_and_a_run_with_the_exact_revision_and_tags() {
|
||||
let api = Fake::default();
|
||||
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(7), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!got.reused);
|
||||
assert_eq!(api.calls().len(), 2, "{:?}", api.calls());
|
||||
let reqs = api.run_requests.lock().unwrap();
|
||||
assert_eq!(reqs[0].task_definition(), Some(td_arn(7).as_str()));
|
||||
assert_eq!(reqs[0].launch_type(), None);
|
||||
let keys: Vec<&str> = reqs[0].tags().iter().filter_map(|t| t.key()).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
["triple-c:environment", "triple-c:owner", "triple-c:project"]
|
||||
);
|
||||
assert_eq!(
|
||||
reqs[0]
|
||||
.tags()
|
||||
.iter()
|
||||
.find(|t| t.key() == Some("triple-c:owner"))
|
||||
.unwrap()
|
||||
.value(),
|
||||
Some(USER)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bare_family_or_foreign_arn_is_refused_before_any_call() {
|
||||
let api = Fake::default();
|
||||
for bad in [
|
||||
FAMILY.to_string(),
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}"),
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}:latest"),
|
||||
"arn:aws:ecs:eu-west-1:1:task-definition/someone-else:1".to_string(),
|
||||
] {
|
||||
let err = find_or_run(&api, &env_info(), USER, PROJECT_ID, &bad, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AwsError::Other { .. }), "{bad}: {err:?}");
|
||||
}
|
||||
assert!(api.calls().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_quota_refusal_is_reported_once_without_retrying() {
|
||||
let api = Fake::default().running(Err(quota_error(
|
||||
"You've reached the limit on the number of vCPUs you can run concurrently",
|
||||
)));
|
||||
let err = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(is_quota_error(&err));
|
||||
let text = err.to_string();
|
||||
assert!(text.contains("limit"), "{text}");
|
||||
assert!(text.contains("quota"), "{text}");
|
||||
assert!(text.contains("vCPUs"), "{text}");
|
||||
assert_eq!(
|
||||
api.calls().iter().filter(|c| *c == "run").count(),
|
||||
1,
|
||||
"no retry"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ quota mapping
|
||||
|
||||
#[test]
|
||||
fn quota_texts_are_recognised_and_others_are_not() {
|
||||
for q in [
|
||||
"You've reached the limit on the number of vCPUs you can run concurrently",
|
||||
"You\u{2019}ve reached the limit on the number of tasks you can run concurrently",
|
||||
"LimitExceededException: too many",
|
||||
"Service quota exceeded for Fargate On-Demand vCPU",
|
||||
"limit exceeded",
|
||||
] {
|
||||
assert!(looks_like_quota(q), "{q}");
|
||||
}
|
||||
for not in [
|
||||
"Capacity is unavailable at this time. Please try again later",
|
||||
"AccessDeniedException: not authorized",
|
||||
"CannotPullContainerError",
|
||||
] {
|
||||
assert!(!looks_like_quota(not), "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_mapping_turns_other_errors_into_quota_errors_and_leaves_the_rest() {
|
||||
let q = quota_or(AwsError::Other {
|
||||
message: "ClientException: You've reached the limit on the number of vCPUs".into(),
|
||||
});
|
||||
assert!(is_quota_error(&q));
|
||||
// The frontend's `quota_exceeded` kind (lib/types.ts).
|
||||
let v = serde_json::to_value(&q).unwrap();
|
||||
assert_eq!(v["kind"], "quota_exceeded");
|
||||
assert!(v["message"].as_str().unwrap().contains("vCPUs"));
|
||||
let denied = AwsError::AccessDenied {
|
||||
action: "ecs:RunTask".into(),
|
||||
message: "limit".into(),
|
||||
};
|
||||
assert_eq!(quota_or(denied.clone()), denied);
|
||||
assert!(!is_quota_error(&AwsError::Other {
|
||||
message: "x".into()
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_task_failures_become_errors_and_quota_failures_quota_errors() {
|
||||
let ok = run_task_outcome(vec![info("a", "PROVISIONING")], vec![]).unwrap();
|
||||
assert_eq!(ok.arn, task_arn("a"));
|
||||
let err = run_task_outcome(
|
||||
vec![],
|
||||
vec![(Some("RESOURCE:ENI".into()), Some("no ENIs left".into()))],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(!is_quota_error(&err));
|
||||
assert!(err.to_string().contains("RESOURCE:ENI"), "{err}");
|
||||
assert!(err.to_string().contains("no ENIs left"), "{err}");
|
||||
let err = run_task_outcome(
|
||||
vec![],
|
||||
vec![(
|
||||
Some("You've reached the limit on the number of tasks you can run concurrently".into()),
|
||||
None,
|
||||
)],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(is_quota_error(&err));
|
||||
let err = run_task_outcome(vec![], vec![]).unwrap_err();
|
||||
assert!(err.to_string().contains("no task"), "{err}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ wait
|
||||
|
||||
fn timing() -> WaitTiming {
|
||||
WaitTiming {
|
||||
interval: Duration::from_secs(5),
|
||||
timeout: Duration::from_secs(360),
|
||||
}
|
||||
}
|
||||
|
||||
fn logs() -> TaskLogs {
|
||||
TaskLogs {
|
||||
group: "/triple-c/dev".into(),
|
||||
stream_prefix: log_stream_prefix(&owner_key(REGION, USER), PROJECT_ID),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn wait_polls_until_running_with_the_exec_agent_and_a_runtime_id() {
|
||||
let mut half = info("t1", "RUNNING");
|
||||
half.runtime_id = Some("rt".into());
|
||||
let api = Fake::default().describing(vec![
|
||||
vec![info("t1", "PROVISIONING")],
|
||||
vec![info("t1", "PENDING")],
|
||||
vec![half],
|
||||
vec![ready("t1")],
|
||||
]);
|
||||
let seen = Mutex::new(Vec::new());
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let got = wait_ready(
|
||||
&api,
|
||||
&cluster(),
|
||||
&task_arn("t1"),
|
||||
&logs(),
|
||||
timing(),
|
||||
|t: &TaskInfo| seen.lock().unwrap().push(t.last_status.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got, ready("t1"));
|
||||
assert_eq!(
|
||||
*seen.lock().unwrap(),
|
||||
["PROVISIONING", "PENDING", "RUNNING", "RUNNING"]
|
||||
);
|
||||
assert_eq!(t0.elapsed(), Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_task_that_stops_while_waiting_reports_its_reason_and_last_log_lines() {
|
||||
let mut stopped = info("t1", "STOPPED");
|
||||
stopped.stop_code = Some("EssentialContainerExited".into());
|
||||
stopped.stopped_reason = Some("Essential container in task exited".into());
|
||||
let api = Fake {
|
||||
logs: vec!["Triple-C: starting".into(), "fatal: boom".into()],
|
||||
..Default::default()
|
||||
}
|
||||
.describing(vec![vec![info("t1", "PENDING")], vec![stopped]]);
|
||||
let err = wait_ready(
|
||||
&api,
|
||||
&cluster(),
|
||||
&task_arn("t1"),
|
||||
&logs(),
|
||||
timing(),
|
||||
|_: &TaskInfo| {},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(
|
||||
text.contains("Essential container in task exited"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("Triple-C: starting\nfatal: boom"), "{text}");
|
||||
let reqs = api.log_requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
*reqs,
|
||||
[(
|
||||
"/triple-c/dev".to_string(),
|
||||
format!("{}/workspace/t1", logs().stream_prefix),
|
||||
50
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_task_being_stopped_fails_the_wait_at_once() {
|
||||
let mut stopping = info("t1", "RUNNING");
|
||||
stopping.desired_status = "STOPPED".into();
|
||||
stopping.stop_code = Some("SpotInterruption".into());
|
||||
stopping.stopped_reason = Some("Your Spot Task was interrupted.".into());
|
||||
let api = Fake::default().describing(vec![vec![stopping]]);
|
||||
let err = wait_ready(
|
||||
&api,
|
||||
&cluster(),
|
||||
&task_arn("t1"),
|
||||
&logs(),
|
||||
timing(),
|
||||
|_: &TaskInfo| {},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Your Spot Task was interrupted."),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_task_that_never_gets_ready_times_out_with_its_last_status() {
|
||||
let api = Fake::default().describing(vec![vec![info("t1", "PENDING")]]);
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let err = wait_ready(
|
||||
&api,
|
||||
&cluster(),
|
||||
&task_arn("t1"),
|
||||
&logs(),
|
||||
timing(),
|
||||
|_: &TaskInfo| {},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(t0.elapsed() >= Duration::from_secs(360));
|
||||
assert!(t0.elapsed() < Duration::from_secs(366));
|
||||
let text = err.to_string();
|
||||
assert!(text.contains("6 minutes"), "{text}");
|
||||
assert!(text.contains("PENDING"), "{text}");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_task_that_vanishes_is_an_error_not_a_hang() {
|
||||
let api = Fake::default().describing(vec![vec![]]);
|
||||
let err = wait_ready(
|
||||
&api,
|
||||
&cluster(),
|
||||
&task_arn("t1"),
|
||||
&logs(),
|
||||
timing(),
|
||||
|_: &TaskInfo| {},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("no longer"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_wait_is_five_seconds_for_six_minutes() {
|
||||
let t = WaitTiming::default();
|
||||
assert_eq!(t.interval, Duration::from_secs(5));
|
||||
assert_eq!(t.timeout, Duration::from_secs(360));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ stop / logs
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_passes_the_reason() {
|
||||
let api = Fake::default();
|
||||
api.stop_task(&cluster(), &task_arn("t1"), STOP_REASON)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
api.calls(),
|
||||
[format!("stop {} Stopped from Triple-C", task_arn("t1"))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_stream_is_prefix_container_task_id() {
|
||||
assert_eq!(
|
||||
task_log_stream("abc/p", &task_arn("0123abcd")),
|
||||
"abc/p/workspace/0123abcd"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopped_among_reports_stopped_and_vanished_tasks_only() {
|
||||
let api = Fake::default().describing(vec![vec![
|
||||
ready("up"),
|
||||
info("down", "STOPPED"),
|
||||
info("pending", "PENDING"),
|
||||
]]);
|
||||
let arns = vec![
|
||||
task_arn("up"),
|
||||
task_arn("down"),
|
||||
task_arn("pending"),
|
||||
task_arn("gone"),
|
||||
];
|
||||
let mut got = stopped_among(&api, &cluster(), &arns).await.unwrap();
|
||||
got.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
(task_arn("down"), StopKind::Exited(None)),
|
||||
(task_arn("gone"), StopKind::Exited(None)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ classify
|
||||
|
||||
#[test]
|
||||
fn classify_stop_follows_the_spec_table() {
|
||||
let with = |code: Option<&str>, reason: Option<&str>| TaskInfo {
|
||||
stop_code: code.map(String::from),
|
||||
stopped_reason: reason.map(String::from),
|
||||
..info("t", "STOPPED")
|
||||
};
|
||||
assert_eq!(
|
||||
classify_stop(&with(Some("EssentialContainerExited"), Some("Essential container in task exited"))),
|
||||
StopKind::Exited(Some("Essential container in task exited".into()))
|
||||
);
|
||||
// The entrypoint's idle stop exits with its own status.
|
||||
let idle = TaskInfo { exit_code: Some(IDLE_EXIT_CODE), ..with(Some("EssentialContainerExited"), None) };
|
||||
assert_eq!(classify_stop(&idle), StopKind::Idle);
|
||||
let crashed = TaskInfo { exit_code: Some(137), ..with(Some("EssentialContainerExited"), None) };
|
||||
assert_eq!(classify_stop(&crashed), StopKind::Exited(None));
|
||||
// Only with that stop code: a user stop of a task that exits 75 is a user stop.
|
||||
let user = TaskInfo { exit_code: Some(IDLE_EXIT_CODE), ..with(Some("UserInitiated"), None) };
|
||||
assert_eq!(classify_stop(&user), StopKind::UserInitiated);
|
||||
assert_eq!(
|
||||
classify_stop(&with(Some("SpotInterruption"), None)),
|
||||
StopKind::Spot
|
||||
);
|
||||
assert_eq!(
|
||||
classify_stop(&with(
|
||||
Some("TaskFailedToStart"),
|
||||
Some("CannotPullContainerError: nope")
|
||||
)),
|
||||
StopKind::FailedToStart("CannotPullContainerError: nope".into())
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_stop(&with(Some("TaskFailedToStart"), None)),
|
||||
StopKind::FailedToStart(r) if !r.is_empty()
|
||||
));
|
||||
assert_eq!(
|
||||
classify_stop(&with(Some("UserInitiated"), Some("Stopped from Triple-C"))),
|
||||
StopKind::UserInitiated
|
||||
);
|
||||
assert_eq!(
|
||||
classify_stop(&with(Some("TerminationNotice"), None)),
|
||||
StopKind::Exited(None)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_stop(&with(Some("ServiceSchedulerInitiated"), None)),
|
||||
StopKind::Exited(None)
|
||||
);
|
||||
assert_eq!(classify_stop(&with(None, Some(" "))), StopKind::Exited(None));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ SDK mapping
|
||||
|
||||
#[test]
|
||||
fn task_info_reads_the_workspace_container_and_its_exec_agent() {
|
||||
let task = Task::builder()
|
||||
.task_arn(task_arn("t1"))
|
||||
.last_status("RUNNING")
|
||||
.desired_status("RUNNING")
|
||||
.health_status(aws_sdk_ecs::types::HealthStatus::Healthy)
|
||||
.stop_code(TaskStopCode::SpotInterruption)
|
||||
.stopped_reason("r")
|
||||
.task_definition_arn(td_arn(3))
|
||||
.containers(
|
||||
Container::builder()
|
||||
.name("sidecar")
|
||||
.runtime_id("wrong")
|
||||
.exit_code(1)
|
||||
.build(),
|
||||
)
|
||||
.containers(
|
||||
Container::builder()
|
||||
.name("workspace")
|
||||
.runtime_id("rt-1")
|
||||
.exit_code(75)
|
||||
.managed_agents(
|
||||
ManagedAgent::builder()
|
||||
.name(ManagedAgentName::ExecuteCommandAgent)
|
||||
.last_status("RUNNING")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.tags(Tag::builder().key("triple-c:owner").value(USER).build())
|
||||
.tags(
|
||||
Tag::builder()
|
||||
.key("triple-c:project")
|
||||
.value(PROJECT_ID)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
let i = task_info(&task);
|
||||
assert_eq!(i.arn, task_arn("t1"));
|
||||
assert_eq!(i.health.as_deref(), Some("HEALTHY"));
|
||||
assert_eq!(i.stop_code.as_deref(), Some("SpotInterruption"));
|
||||
assert_eq!(i.runtime_id.as_deref(), Some("rt-1"));
|
||||
assert!(i.exec_agent_running);
|
||||
assert_eq!(i.exit_code, Some(75), "the workspace container's, not the sidecar's");
|
||||
assert_eq!(i.owner.as_deref(), Some(USER));
|
||||
assert_eq!(i.project.as_deref(), Some(PROJECT_ID));
|
||||
|
||||
let bare = task_info(&Task::builder().task_arn("x").last_status("PENDING").build());
|
||||
assert!(!bare.exec_agent_running);
|
||||
assert_eq!(bare.runtime_id, None);
|
||||
assert_eq!(bare.exit_code, None);
|
||||
assert_eq!(bare.owner, None);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,351 @@
|
||||
//! 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()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// An environment's ECS cluster name (the environment module's).
|
||||
pub fn environment_cluster_name(env: &str) -> String {
|
||||
format!("triple-c-{env}")
|
||||
}
|
||||
|
||||
/// An environment's CloudWatch log group (the environment module's).
|
||||
pub fn environment_log_group(env: &str) -> String {
|
||||
format!("/triple-c/{env}")
|
||||
}
|
||||
|
||||
/// 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"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a project id is used in names as it is: 1 to 64 characters of
|
||||
/// `[A-Za-z0-9-]`. Every UUID (how the app makes ids) is.
|
||||
fn is_clean_project_id(id: &str) -> bool {
|
||||
(1..=64).contains(&id.len()) && id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
|
||||
}
|
||||
|
||||
/// 16 hex characters of `sha256(id)`.
|
||||
fn project_id_hash(id: &str) -> String {
|
||||
Sha256::digest(id.as_bytes())
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A project id as a name component, safe in a secret name, an EFS path and
|
||||
/// a log stream. A clean id (see [`is_clean_project_id`]) is used as is.
|
||||
/// Anything else — only a hand-edited `projects.json` has one — becomes its
|
||||
/// first 40 characters with everything outside `[A-Za-z0-9-]` replaced by
|
||||
/// `-`, then `_` and a hash of the whole id. Clean ids never contain `_`, so
|
||||
/// the two forms can't collide, and ids that sanitise alike (`a/b`, `a:b`)
|
||||
/// still differ by the hash.
|
||||
fn project_component(project_id: &str) -> String {
|
||||
if is_clean_project_id(project_id) {
|
||||
return project_id.to_string();
|
||||
}
|
||||
let prefix: String = project_id
|
||||
.chars()
|
||||
.take(40)
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{prefix}_{}", project_id_hash(project_id))
|
||||
}
|
||||
|
||||
/// A project's ECS task definition family:
|
||||
/// `triple-c-<env>-<owner_key>-<first 12 of the project id>` (an id that
|
||||
/// isn't clean contributes `_` and 11 hex of its hash instead). At most 55
|
||||
/// characters of `[A-Za-z0-9_-]`. Other users can register revisions into
|
||||
/// any `triple-c-*` family, so a family is never trusted by name alone (see
|
||||
/// `aws::workspace`), and two of a user's projects could share 12 leading
|
||||
/// characters, so a family's tasks and revisions are told apart by their
|
||||
/// `triple-c:project` tag.
|
||||
pub fn task_family(env: &str, owner_key: &str, project_id: &str) -> String {
|
||||
let id: String = if is_clean_project_id(project_id) {
|
||||
project_id.chars().take(12).collect()
|
||||
} else {
|
||||
format!("_{}", &project_id_hash(project_id)[..11])
|
||||
};
|
||||
format!("triple-c-{env}-{owner_key}-{id}")
|
||||
}
|
||||
|
||||
/// The project's Secrets Manager secret (P1): `triple-c/<env>/<owner_key>/<project id>`.
|
||||
pub fn project_secret_name(env: &str, owner_key: &str, project_id: &str) -> String {
|
||||
format!(
|
||||
"triple-c/{env}/{owner_key}/{}",
|
||||
project_component(project_id)
|
||||
)
|
||||
}
|
||||
|
||||
/// The root of the project's EFS access point (P4), at most 90 characters.
|
||||
pub fn access_point_root(owner_key: &str, project_id: &str) -> String {
|
||||
format!("/triple-c/{owner_key}/{}", project_component(project_id))
|
||||
}
|
||||
|
||||
/// The container's `awslogs-stream-prefix`; ECS names the stream
|
||||
/// `<prefix>/workspace/<task id>`.
|
||||
pub fn log_stream_prefix(owner_key: &str, project_id: &str) -> String {
|
||||
format!("{owner_key}/{}", project_component(project_id))
|
||||
}
|
||||
|
||||
/// The awslogs stream of a task's `workspace` container:
|
||||
/// `<prefix>/<container>/<task-id>`, the task id being the last segment of
|
||||
/// its ARN.
|
||||
pub fn task_log_stream(stream_prefix: &str, task_arn: &str) -> String {
|
||||
let task_id = task_arn.rsplit('/').next().unwrap_or(task_arn);
|
||||
format!("{stream_prefix}/workspace/{task_id}")
|
||||
}
|
||||
|
||||
/// The `creation_token` of the user's file system (the onboarding module's
|
||||
/// `local.name`).
|
||||
pub fn user_file_system_token(env: &str, owner_key: &str) -> String {
|
||||
format!("triple-c-{env}-{owner_key}")
|
||||
}
|
||||
|
||||
/// 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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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))?;
|
||||
|
||||
@@ -34,6 +34,11 @@ pub async fn get_container_info(
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
// An AWS project has no Docker container (its own name could match a
|
||||
// stale one from before it moved to AWS).
|
||||
if project.is_aws() {
|
||||
return Ok(None);
|
||||
}
|
||||
docker::get_container_info(&project).await
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
//! 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);
|
||||
let names: Vec<&str> = ws.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["workspace-user.json", "workspace-user-tasks.json"]);
|
||||
for f in &ws {
|
||||
serde_json::from_str::<serde_json::Value>(&f.json).expect("policy JSON");
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_value(&ws[0]).unwrap()["name"],
|
||||
"workspace-user.json"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -432,6 +432,11 @@ pub async fn get_container_staleness(
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
// An AWS task has no local container or snapshot to compare: no claim
|
||||
// either way ("unknown"), and nothing is probed.
|
||||
if project.is_aws() {
|
||||
return Ok(ContainerStaleness::default());
|
||||
}
|
||||
let settings = state.settings_store.get();
|
||||
let base_image = crate::models::container_config::resolve_image_name(
|
||||
&settings.image_source,
|
||||
@@ -708,6 +713,9 @@ pub async fn migrate_project_to_base(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MigrationReport, String> {
|
||||
if let Some(p) = state.projects_store.get(&project_id) {
|
||||
crate::commands::project_commands::refuse_aws(&p, "Updating to the current base image")?;
|
||||
}
|
||||
let _guard = match ActiveGuard::acquire(&project_id) {
|
||||
Ok(guard) => guard,
|
||||
Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)),
|
||||
|
||||
@@ -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;
|
||||
@@ -13,6 +15,7 @@ pub mod migration_commands;
|
||||
pub mod notes_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod stop_guard;
|
||||
pub mod settings_export_commands;
|
||||
pub mod stt_commands;
|
||||
pub mod terminal_commands;
|
||||
|
||||
@@ -4,7 +4,10 @@ use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::host::lifecycle::{DiscardLeftovers, ProjectHost};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::models::{
|
||||
AwsPlacement, AwsWorkspaceConfig, Backend, BedrockAuthMethod, Project, ProjectPath,
|
||||
ProjectRemovalReport, ProjectResetOutcome, ProjectStatus, UnsavedWork, WipChoice,
|
||||
};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -633,9 +636,47 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, S
|
||||
pub async fn add_project(
|
||||
name: String,
|
||||
paths: Vec<ProjectPath>,
|
||||
host_id: Option<String>,
|
||||
aws_placement: Option<AwsPlacement>,
|
||||
aws: Option<AwsWorkspaceConfig>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
// Validate paths
|
||||
let project = new_project(name, paths, host_id, aws_placement, aws, &state.settings_store.get().hosts)?;
|
||||
// Nothing can have been blanked on a project that did not exist a line ago.
|
||||
store_secrets_for_project(&project, &[])?;
|
||||
state.projects_store.add(project)
|
||||
}
|
||||
|
||||
/// The project `add_project` creates. A local project needs a folder; an
|
||||
/// AWS project (all three host fields) clones repositories instead and
|
||||
/// takes no folders. The host fields are checked as a save would check them.
|
||||
fn new_project(
|
||||
name: String,
|
||||
paths: Vec<ProjectPath>,
|
||||
host_id: Option<String>,
|
||||
aws_placement: Option<AwsPlacement>,
|
||||
aws: Option<AwsWorkspaceConfig>,
|
||||
hosts: &[crate::models::HostConnection],
|
||||
) -> Result<Project, String> {
|
||||
let on_host = host_id.is_some() || aws_placement.is_some() || aws.is_some();
|
||||
if on_host {
|
||||
if paths.iter().any(|p| !p.host_path.is_empty() || !p.mount_name.is_empty()) {
|
||||
return Err("A project on AWS clones repositories; it can't mount folders from this machine.".into());
|
||||
}
|
||||
let blank = Project::new(name.clone(), Vec::new());
|
||||
let mut project = Project::new(name, Vec::new());
|
||||
project.host_id = host_id;
|
||||
project.aws_placement = aws_placement;
|
||||
// Runtime handles belong to the start path, never to the caller.
|
||||
project.aws = aws.map(|a| AwsWorkspaceConfig {
|
||||
task_arn: None,
|
||||
access_point_id: None,
|
||||
task_definition_arn: None,
|
||||
..a
|
||||
});
|
||||
validate_aws_update(&blank, &project, hosts)?;
|
||||
return Ok(project);
|
||||
}
|
||||
// A new project needs a folder; the blank row `validate_project_paths`
|
||||
// tolerates is the Config tab's placeholder on an *existing* one.
|
||||
if paths.is_empty()
|
||||
@@ -646,15 +687,14 @@ pub async fn add_project(
|
||||
return Err("At least one folder path is required.".to_string());
|
||||
}
|
||||
validate_project_paths(&paths)?;
|
||||
let project = Project::new(name, paths);
|
||||
// Nothing can have been blanked on a project that did not exist a line ago.
|
||||
store_secrets_for_project(&project, &[])?;
|
||||
state.projects_store.add(project)
|
||||
Ok(Project::new(name, paths))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_project(
|
||||
project_id: String,
|
||||
discard_data: Option<bool>,
|
||||
forget_aws_resources: Option<bool>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectRemovalReport, String> {
|
||||
// **H-2: the only writer of these three categories that held nothing.**
|
||||
@@ -673,6 +713,34 @@ pub async fn remove_project(
|
||||
let _guard =
|
||||
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Destroy)?;
|
||||
|
||||
// An AWS project's task and resources go first, and a failure keeps the
|
||||
// project: its record is the only handle on a billed task and its
|
||||
// secret. Its EFS access point goes only when asked (the files stay on the
|
||||
// user's own file system until offboarding).
|
||||
if let Some(project) = state.projects_store.get(&project_id).filter(|p| p.is_aws()) {
|
||||
let settings = state.settings_store.get();
|
||||
if let Some(target) = ContainerTarget::for_project(&project) {
|
||||
state.exec_manager.close_sessions_for_container(target.id()).await;
|
||||
}
|
||||
let connection_exists = project
|
||||
.host_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| settings.hosts.iter().any(|h| h.id == id));
|
||||
if connection_exists {
|
||||
ProjectHost::for_project(&project)
|
||||
.remove_aws_resources(&project, &settings, discard_data.unwrap_or(false))
|
||||
.await?;
|
||||
} else {
|
||||
missing_connection_removal(forget_aws_resources.unwrap_or(false))?;
|
||||
log::warn!(
|
||||
"Project {}: removed without its AWS connection; its task, secret and data (if \
|
||||
any) are left in AWS, as the user confirmed",
|
||||
project_id
|
||||
);
|
||||
crate::host::tunnels::close(&project_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Release any host loopback ports the auth bridge holds for this project
|
||||
// before the container (and the project record) go away.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
@@ -1054,6 +1122,8 @@ pub async fn update_project(
|
||||
// [`crate::models::validate_env_vars_update`].
|
||||
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
|
||||
|
||||
validate_aws_update(&stored, &project, &state.settings_store.get().hosts)?;
|
||||
|
||||
restore_store_owned_fields(&mut project, &stored);
|
||||
project.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
@@ -1115,6 +1185,59 @@ fn restore_store_owned_fields(project: &mut Project, stored: &Project) {
|
||||
// Owned by the marketplace commands; a Config-tab save carries a stale copy.
|
||||
project.marketplace_installs = stored.marketplace_installs.clone();
|
||||
project.marketplace_disabled = stored.marketplace_disabled.clone();
|
||||
// Runtime handles of an AWS task belong to the start/stop path.
|
||||
if let Some(aws) = project.aws.as_mut() {
|
||||
let s = stored.aws.as_ref();
|
||||
aws.task_arn = s.and_then(|a| a.task_arn.clone());
|
||||
aws.access_point_id = s.and_then(|a| a.access_point_id.clone());
|
||||
aws.task_definition_arn = s.and_then(|a| a.task_definition_arn.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks an incoming save's host placement against what is stored and the
|
||||
/// configured connections.
|
||||
fn validate_aws_update(
|
||||
stored: &Project,
|
||||
project: &Project,
|
||||
hosts: &[crate::models::HostConnection],
|
||||
) -> Result<(), String> {
|
||||
match (&project.host_id, &project.aws_placement, &project.aws) {
|
||||
(None, None, None) => {}
|
||||
(Some(id), Some(place), Some(cfg)) => {
|
||||
if !hosts.iter().any(|h| &h.id == id) {
|
||||
return Err(format!("Host connection \"{}\" does not exist", id.escape_debug()));
|
||||
}
|
||||
crate::models::validate_region(&place.region)?;
|
||||
crate::aws::tofu_modules::validate_environment_name(&place.environment)?;
|
||||
crate::models::validate_aws_workspace(cfg)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"A host project needs a host, a placement and a workspace configuration together"
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
let moved = stored.host_id != project.host_id || stored.aws_placement != project.aws_placement;
|
||||
if moved && stored.status != ProjectStatus::Stopped {
|
||||
return Err("Stop the project before changing its host or placement".into());
|
||||
}
|
||||
// The task, access point and task definition live in the stored
|
||||
// connection's account and region; moving the project would orphan them.
|
||||
if moved
|
||||
&& stored.aws.as_ref().is_some_and(|a| {
|
||||
a.task_arn.is_some() || a.access_point_id.is_some() || a.task_definition_arn.is_some()
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"This project still has AWS resources (a task, its saved files' access point or task \
|
||||
definitions) where it runs now; remove the workspace's AWS resources first — \
|
||||
including the access point (\"Also remove the workspace's file-system access \
|
||||
point\"), which stays when the saved files are kept"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1282,7 +1405,7 @@ async fn start_project_container_locked(
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let result: Result<ContainerTarget, String> = async {
|
||||
let target = host
|
||||
.ensure_started(&project, &settings, &|msg: &str| {
|
||||
.ensure_started(&project, &settings, &state.projects_store, &|msg: &str| {
|
||||
emit_progress(&app_handle, &project_id, msg)
|
||||
})
|
||||
.await?;
|
||||
@@ -1307,10 +1430,24 @@ async fn start_project_container_locked(
|
||||
Ok(target)
|
||||
}.await;
|
||||
|
||||
// On failure, reset status to Stopped so the project doesn't get stuck.
|
||||
// On failure, reset status to Stopped so the project doesn't get stuck —
|
||||
// unless an AWS task is still running (an attach that failed, or a start
|
||||
// that lost the race): Stopped would hide a billed task, so the project
|
||||
// is left in Error with its task ARN, which Stop and Start both use.
|
||||
if let Err(ref e) = result {
|
||||
log::error!("Failed to start container for project {}: {}", project_id, e);
|
||||
let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped);
|
||||
let task_left = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.is_some_and(|p| aws_task_left_after_failed_start(&p));
|
||||
let status = if task_left { ProjectStatus::Error } else { ProjectStatus::Stopped };
|
||||
let _ = state.projects_store.update_status(&project_id, status);
|
||||
if task_left {
|
||||
return Err(format!(
|
||||
"{e}\n\nThe workspace's task is still running on AWS. Start again to connect to \
|
||||
it, or Stop to stop it."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let target = result?;
|
||||
@@ -1318,6 +1455,21 @@ async fn start_project_container_locked(
|
||||
|
||||
// Update project with container info using granular methods (Issue 14: TOCTOU)
|
||||
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||
// AWS: the task's handles, store-owned like the container id. The target
|
||||
// id stored above is the tunnel's endpoint URL, which is what the death
|
||||
// watch reports a stopped task as.
|
||||
if project.is_aws() {
|
||||
if let Some((task_arn, access_point_id, task_definition_arn)) =
|
||||
crate::host::tunnels::runtime(&project_id)
|
||||
{
|
||||
state.projects_store.set_aws_runtime(
|
||||
&project_id,
|
||||
Some(task_arn),
|
||||
access_point_id,
|
||||
Some(task_definition_arn),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
||||
|
||||
// Arm the auth bridge if this project opted in. Purely host-side, so it
|
||||
@@ -1342,9 +1494,10 @@ async fn start_project_container_locked(
|
||||
#[tauri::command]
|
||||
pub async fn stop_project_container(
|
||||
project_id: String,
|
||||
wip: Option<WipChoice>,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Option<String>, String> {
|
||||
// Stop had **no** exclusion at all, which is the one gap CLAUDE.md's "every
|
||||
// command that stops, removes or recreates the container consults
|
||||
// `is_migrating`" rule already named and this function did not honour. A
|
||||
@@ -1358,12 +1511,84 @@ pub async fn stop_project_container(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
// The stop guard asked, and meanwhile the workspace stopped by itself
|
||||
// (idle stop, another window): what the user wanted has happened.
|
||||
if project.is_aws() && wip.is_some() && project.status == ProjectStatus::Stopped {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||
|
||||
// Drop host listeners first: they only make sense while the container runs.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
// AWS: the terminals and the auth bridge close *before* the stop guard's
|
||||
// push, so nothing written after its snapshot is silently dropped (the
|
||||
// entrypoint's hook skips its own push once `pushed` is recorded). A push
|
||||
// that fails aborts the stop with the status as it was; the auth bridge
|
||||
// is re-armed, and the error says the terminals were closed.
|
||||
let mut note = None;
|
||||
let guard_target = if project.is_aws() { ContainerTarget::for_project(&project) } else { None };
|
||||
if project.is_aws() {
|
||||
let close_first = async {
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
if let Some(container) = guard_target.as_ref() {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
if wip == Some(WipChoice::Push) {
|
||||
emit_progress(&app_handle, &project_id, "Pushing unsaved work...");
|
||||
}
|
||||
};
|
||||
let guarded = aws_guard_before_stop(
|
||||
&state.projects_store,
|
||||
&project_id,
|
||||
project.status.clone(),
|
||||
guard_target.as_ref(),
|
||||
wip,
|
||||
super::stop_guard::PUSH_TIMEOUT,
|
||||
close_first,
|
||||
)
|
||||
.await;
|
||||
match guarded {
|
||||
Ok(n) => note = n,
|
||||
Err(e) => {
|
||||
if project.auth_bridge_enabled {
|
||||
if let Some(target) = guard_target.clone() {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(project_id.clone(), target, app_handle.clone(), state.projects_store.clone())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Drop host listeners first: they only make sense while the container runs.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
}
|
||||
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
if project.is_aws() {
|
||||
// A failed StopTask leaves a billed task running, so unlike Docker it
|
||||
// is not papered over: the project keeps its status and the user
|
||||
// sees why (expired SSO, typically), and can retry. Its terminals
|
||||
// and auth bridge were closed before the stop guard's step above.
|
||||
emit_progress(&app_handle, &project_id, "Stopping task...");
|
||||
let settings = state.settings_store.get();
|
||||
if let Err(e) = ProjectHost::for_project(&project).stop_project(&project, &settings).await {
|
||||
log::warn!("Stop failed for project {}: {}", project_id, e);
|
||||
// Still running: a later stop must push as configured again.
|
||||
super::stop_guard::forget_choice(guard_target.as_ref(), wip).await;
|
||||
let _ = state.projects_store.update_status(&project_id, project.status.clone());
|
||||
return Err(format!("Could not stop the task: {e}"));
|
||||
}
|
||||
if let Some(a) = project.aws.as_ref() {
|
||||
state.projects_store.set_aws_runtime(
|
||||
&project_id,
|
||||
None,
|
||||
a.access_point_id.clone(),
|
||||
a.task_definition_arn.clone(),
|
||||
)?;
|
||||
}
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
} else 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;
|
||||
@@ -1374,6 +1599,127 @@ pub async fn stop_project_container(
|
||||
}
|
||||
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Appended to a failed stop-guard push: its terminals were closed first.
|
||||
const TERMINALS_CLOSED_FOR_PUSH: &str =
|
||||
"Its terminals were closed before the push, so nothing written after it could be lost; \
|
||||
open them again to keep working.";
|
||||
|
||||
/// The AWS stop guard's step before StopTask ([`super::stop_guard::prepare_stop`]),
|
||||
/// after `close_first` (the project's terminals and auth bridge): whatever
|
||||
/// they write after the push's snapshot would otherwise be lost, since the
|
||||
/// entrypoint's hook does not push again after `pushed`. A failed push puts
|
||||
/// `previous` back, so the project's status is as it was.
|
||||
async fn aws_guard_before_stop<R, F>(
|
||||
store: &crate::storage::projects_store::ProjectsStore,
|
||||
project_id: &str,
|
||||
previous: ProjectStatus,
|
||||
runner: Option<&R>,
|
||||
wip: Option<WipChoice>,
|
||||
push_timeout: std::time::Duration,
|
||||
close_first: F,
|
||||
) -> Result<Option<String>, String>
|
||||
where
|
||||
R: super::stop_guard::WipRunner,
|
||||
F: std::future::Future<Output = ()>,
|
||||
{
|
||||
close_first.await;
|
||||
match super::stop_guard::prepare_stop(runner, wip, push_timeout).await {
|
||||
Ok(note) => Ok(note),
|
||||
Err(e) => {
|
||||
log::warn!("Project {}: WIP push before stop failed: {}", project_id, e);
|
||||
let _ = store.update_status(project_id, previous);
|
||||
Err(format!("{e}\n\n{TERMINALS_CLOSED_FOR_PUSH}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stop guard's question: what would stopping this AWS workspace lose?
|
||||
/// A local project (or a stopped one) has nothing to check.
|
||||
#[tauri::command]
|
||||
pub async fn check_unsaved_work(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<UnsavedWork, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
if !project.is_aws() || project.status != ProjectStatus::Running {
|
||||
return Ok(UnsavedWork { checked: true, ..Default::default() });
|
||||
}
|
||||
let target = ContainerTarget::for_project(&project);
|
||||
Ok(super::stop_guard::unsaved_work(target.as_ref(), super::stop_guard::DRY_RUN_TIMEOUT).await)
|
||||
}
|
||||
|
||||
/// The last `limit` (1–200) lines of the AWS project's current or last task's
|
||||
/// log. Empty when there is no task to read (or the project is local).
|
||||
#[tauri::command]
|
||||
pub async fn aws_task_log(
|
||||
project_id: String,
|
||||
limit: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
if !project.is_aws() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let settings = state.settings_store.get();
|
||||
crate::host::aws_host::task_log(&project, &settings, limit.clamp(1, 200) as i32).await
|
||||
}
|
||||
|
||||
/// Remove an AWS project's task definitions, secret and (with
|
||||
/// `discard_data`) saved files, keeping the project, so it can move to
|
||||
/// another host. Stopped projects only.
|
||||
#[tauri::command]
|
||||
pub async fn remove_aws_resources(
|
||||
project_id: String,
|
||||
discard_data: bool,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
let _guard =
|
||||
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Destroy)?;
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
check_aws_resources_removable(&project)?;
|
||||
let settings = state.settings_store.get();
|
||||
ProjectHost::for_project(&project)
|
||||
.remove_aws_resources(&project, &settings, discard_data)
|
||||
.await?;
|
||||
state
|
||||
.projects_store
|
||||
.set_aws_runtime(&project_id, None, access_point_after_removal(&project, discard_data), None)?;
|
||||
state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))
|
||||
}
|
||||
|
||||
/// The access point a removal leaves stored: kept when the saved files were
|
||||
/// kept (it is still there, a later start reuses it and a later removal
|
||||
/// with `discard_data` deletes it; and while it is stored the project can't
|
||||
/// move away from it — [`validate_aws_update`]), gone when they were not.
|
||||
fn access_point_after_removal(project: &Project, discard_data: bool) -> Option<String> {
|
||||
if discard_data {
|
||||
return None;
|
||||
}
|
||||
project.aws.as_ref().and_then(|a| a.access_point_id.clone())
|
||||
}
|
||||
|
||||
fn check_aws_resources_removable(project: &Project) -> Result<(), String> {
|
||||
if !project.is_aws() {
|
||||
return Err("This project does not run on AWS".into());
|
||||
}
|
||||
if project.status != ProjectStatus::Stopped {
|
||||
return Err("Stop the project before removing its AWS resources".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1397,6 +1743,7 @@ pub async fn rebuild_project_container(
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
refuse_aws(&project, "Reset")?;
|
||||
|
||||
// Reset supersedes any migration decision that was still pending: the
|
||||
// snapshot image and both volumes are about to go, so a surviving record
|
||||
@@ -1472,6 +1819,44 @@ pub async fn rebuild_project_container(
|
||||
/// [`crate::commands::migration_commands::reconcile_migration`]. The migration
|
||||
/// pass runs over *every* project, not just the Running ones, because a project
|
||||
/// whose container was removed mid-migration reports Stopped.
|
||||
/// Removing an AWS project whose connection was deleted can't reach AWS:
|
||||
/// refused unless the user confirmed leaving its resources behind.
|
||||
/// Stable prefix of that refusal, so the UI can offer the second confirm
|
||||
/// without matching prose. Mirrored by `AWS_CONNECTION_MISSING` in `lib/types.ts`.
|
||||
pub const AWS_CONNECTION_MISSING: &str = "AWS_CONNECTION_MISSING: ";
|
||||
|
||||
fn missing_connection_removal(forget_aws_resources: bool) -> Result<(), String> {
|
||||
if forget_aws_resources {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AWS_CONNECTION_MISSING.to_string()
|
||||
+ "This project's AWS connection no longer exists, so its task (which may still be \
|
||||
running and billed), secret and data can't be removed from here. Add the connection \
|
||||
again in Settings → Hosts and remove the project, or confirm removing it while leaving \
|
||||
those resources in AWS.")
|
||||
}
|
||||
|
||||
/// After a failed start: an AWS project whose task ARN is still stored has
|
||||
/// a task that may still be running (a start stops, and forgets, only a task
|
||||
/// it ran itself).
|
||||
fn aws_task_left_after_failed_start(project: &Project) -> bool {
|
||||
project.is_aws() && project.aws.as_ref().is_some_and(|a| a.task_arn.is_some())
|
||||
}
|
||||
|
||||
/// How long reconcile waits on AWS for one project.
|
||||
const AWS_RECONCILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Refuse a Docker-only operation on an AWS project.
|
||||
pub(crate) fn refuse_aws(project: &Project, what: &str) -> Result<(), String> {
|
||||
if project.is_aws() {
|
||||
return Err(format!(
|
||||
"{what} is not available for a project that runs on AWS; it applies to local \
|
||||
Docker containers only"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reconcile_project_statuses(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1479,7 +1864,7 @@ pub async fn reconcile_project_statuses(
|
||||
) -> Result<Vec<Project>, String> {
|
||||
let projects = state.projects_store.list();
|
||||
|
||||
for project in &projects {
|
||||
for project in projects.iter().filter(|p| !p.is_aws()) {
|
||||
crate::commands::migration_commands::reconcile_migration(project, &app_handle).await;
|
||||
}
|
||||
|
||||
@@ -1513,6 +1898,12 @@ pub async fn reconcile_project_statuses(
|
||||
continue;
|
||||
}
|
||||
|
||||
// AWS projects are reconciled by `reconcile_aws_projects`, which the
|
||||
// UI runs at startup whether or not Docker answers.
|
||||
if project.is_aws() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_running = if let Some(container) = ContainerTarget::for_project(project) {
|
||||
container.is_running().await.unwrap_or(false)
|
||||
} else {
|
||||
@@ -1555,6 +1946,62 @@ pub async fn reconcile_project_statuses(
|
||||
}
|
||||
|
||||
|
||||
/// How many AWS projects reconcile at once.
|
||||
const AWS_RECONCILE_CONCURRENCY: usize = 4;
|
||||
|
||||
/// Reconcile every AWS project against ECS (the authority), independently of
|
||||
/// Docker: an app started without Docker still learns which tasks run and
|
||||
/// re-opens their tunnels. One bounded DescribeTasks per project, a few at a
|
||||
/// time; a live task's tunnel is re-opened in the background.
|
||||
#[tauri::command]
|
||||
pub async fn reconcile_aws_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let projects: Vec<Project> = state.projects_store.list().into_iter().filter(|p| p.is_aws()).collect();
|
||||
// A target id left from the last run is a loopback URL whose port a new
|
||||
// tunnel (any project's) may now own; until a re-attach registers, the
|
||||
// project gets an id no tunnel can have (m9).
|
||||
// A stopped mark a past unattached spell left on that id is forgotten
|
||||
// first: reconcile asks ECS now, and a stale mark would make the death
|
||||
// watch report a live, billed task as stopped.
|
||||
for p in &projects {
|
||||
if let Some(id) = crate::host::aws_host::prepare_unattached(p) {
|
||||
let _ = state.projects_store.set_container_id(&p.id, Some(id));
|
||||
}
|
||||
}
|
||||
let settings = state.settings_store.get();
|
||||
let store = state.projects_store.clone();
|
||||
futures_util::stream::iter(projects.into_iter().filter(|p| {
|
||||
matches!(
|
||||
p.status,
|
||||
ProjectStatus::Running | ProjectStatus::Error | ProjectStatus::Starting | ProjectStatus::Stopping
|
||||
) && crate::project_lock::held(&p.id).is_none()
|
||||
}))
|
||||
.for_each_concurrent(AWS_RECONCILE_CONCURRENCY, |project| {
|
||||
let settings = settings.clone();
|
||||
let store = store.clone();
|
||||
async move {
|
||||
// The store's current copy: the id above may have changed.
|
||||
let project = store.get(&project.id).unwrap_or(project);
|
||||
if tokio::time::timeout(
|
||||
AWS_RECONCILE_TIMEOUT,
|
||||
crate::host::aws_host::reconcile(&project, &settings, store),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
log::warn!(
|
||||
"Project {}: AWS did not answer within {}s; status left as is",
|
||||
project.id,
|
||||
AWS_RECONCILE_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
Ok(state.projects_store.list())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2161,4 +2608,276 @@ mod tests {
|
||||
assert_eq!(payload.marketplace_installs, stored.marketplace_installs);
|
||||
assert_eq!(payload.marketplace_disabled, stored.marketplace_disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_cannot_change_the_aws_runtime_handles() {
|
||||
let (mut stored, mut payload) = stored_and_stale_payload();
|
||||
let mut sc = crate::models::AwsWorkspaceConfig::default();
|
||||
sc.task_arn = Some("arn:task".into());
|
||||
sc.access_point_id = Some("fsap-1".into());
|
||||
sc.task_definition_arn = Some("arn:td".into());
|
||||
stored.aws = Some(sc.clone());
|
||||
let mut pc = crate::models::AwsWorkspaceConfig::default();
|
||||
pc.cpu = 2048;
|
||||
pc.memory_mib = 8192;
|
||||
pc.task_arn = Some("evil".into());
|
||||
pc.access_point_id = None;
|
||||
payload.aws = Some(pc);
|
||||
|
||||
restore_store_owned_fields(&mut payload, &stored);
|
||||
|
||||
let a = payload.aws.unwrap();
|
||||
assert_eq!(a.task_arn, sc.task_arn);
|
||||
assert_eq!(a.access_point_id, sc.access_point_id);
|
||||
assert_eq!(a.task_definition_arn, sc.task_definition_arn);
|
||||
assert_eq!(a.cpu, 2048, "the edit itself goes through");
|
||||
}
|
||||
|
||||
fn host(id: &str) -> crate::models::HostConnection {
|
||||
crate::models::HostConnection {
|
||||
id: id.into(),
|
||||
name: "n".into(),
|
||||
kind: crate::models::HostKind::Aws {
|
||||
auth: crate::models::AwsAuth::Profile { name: "p".into() },
|
||||
default_region: "us-west-2".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn aws_project() -> Project {
|
||||
let (mut p, _) = stored_and_stale_payload();
|
||||
p.status = ProjectStatus::Stopped;
|
||||
p.host_id = Some("h1".into());
|
||||
p.aws_placement = Some(crate::models::AwsPlacement { region: "us-west-2".into(), environment: "dev".into() });
|
||||
p.aws = Some(Default::default());
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_update_rules() {
|
||||
let hosts = vec![host("h1")];
|
||||
let local = { let mut p = aws_project(); p.host_id = None; p.aws_placement = None; p.aws = None; p };
|
||||
assert!(validate_aws_update(&local, &local, &hosts).is_ok());
|
||||
let aws = aws_project();
|
||||
assert!(validate_aws_update(&aws, &aws, &hosts).is_ok());
|
||||
// moving a stopped project onto a host is fine
|
||||
assert!(validate_aws_update(&local, &aws, &hosts).is_ok());
|
||||
// unknown connection
|
||||
assert!(validate_aws_update(&local, &aws, &[]).unwrap_err().contains("does not exist"));
|
||||
// partial triple
|
||||
let mut half = aws_project(); half.aws = None;
|
||||
assert!(validate_aws_update(&aws, &half, &hosts).is_err());
|
||||
let mut half = local.clone(); half.aws = Some(Default::default());
|
||||
assert!(validate_aws_update(&local, &half, &hosts).is_err());
|
||||
// bad region / environment / workspace
|
||||
let mut bad = aws_project(); bad.aws_placement.as_mut().unwrap().region = "nope".into();
|
||||
assert!(validate_aws_update(&aws, &bad, &hosts).is_err());
|
||||
let mut bad = aws_project(); bad.aws_placement.as_mut().unwrap().environment = "Bad_Env".into();
|
||||
assert!(validate_aws_update(&aws, &bad, &hosts).is_err());
|
||||
let mut bad = aws_project(); bad.aws.as_mut().unwrap().cpu = 300;
|
||||
assert!(validate_aws_update(&aws, &bad, &hosts).is_err());
|
||||
// host/placement frozen while not stopped; other edits still fine
|
||||
let mut running = aws_project(); running.status = ProjectStatus::Running;
|
||||
let mut moved = running.clone(); moved.aws_placement.as_mut().unwrap().environment = "prod".into();
|
||||
assert!(validate_aws_update(&running, &moved, &hosts).unwrap_err().contains("Stop the project"));
|
||||
let mut moved = running.clone(); moved.host_id = None; moved.aws_placement = None; moved.aws = None;
|
||||
assert!(validate_aws_update(&running, &moved, &hosts).is_err());
|
||||
let mut edited = running.clone(); edited.aws.as_mut().unwrap().spot = true;
|
||||
assert!(validate_aws_update(&running, &edited, &hosts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removal_that_keeps_the_saved_files_keeps_their_access_point_and_the_project_in_place() {
|
||||
let hosts = vec![host("h1")];
|
||||
let mut stored = aws_project();
|
||||
stored.aws.as_mut().unwrap().access_point_id = Some("fsap-1".into());
|
||||
assert_eq!(access_point_after_removal(&stored, false), Some("fsap-1".into()));
|
||||
assert_eq!(access_point_after_removal(&stored, true), None);
|
||||
// What the store holds after a keep-the-files removal still blocks a move.
|
||||
let mut after = stored.clone();
|
||||
let a = after.aws.as_mut().unwrap();
|
||||
a.task_arn = None;
|
||||
a.task_definition_arn = None;
|
||||
a.access_point_id = access_point_after_removal(&stored, false);
|
||||
let mut local = after.clone();
|
||||
local.host_id = None;
|
||||
local.aws_placement = None;
|
||||
local.aws = None;
|
||||
let e = validate_aws_update(&after, &local, &hosts).unwrap_err();
|
||||
assert!(e.contains("access point"), "{e}");
|
||||
// After a discarding removal it can move.
|
||||
after.aws.as_mut().unwrap().access_point_id = access_point_after_removal(&stored, true);
|
||||
assert!(validate_aws_update(&after, &local, &hosts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_aws_resources_cannot_be_moved_off_its_host() {
|
||||
let hosts = vec![host("h1"), host("h2")];
|
||||
let mut stored = aws_project();
|
||||
stored.aws.as_mut().unwrap().access_point_id = Some("fsap-1".into());
|
||||
let mut local = stored.clone();
|
||||
local.host_id = None;
|
||||
local.aws_placement = None;
|
||||
local.aws = None;
|
||||
let e = validate_aws_update(&stored, &local, &hosts).unwrap_err();
|
||||
assert!(e.contains("remove the workspace's AWS resources first"), "{e}");
|
||||
let mut other = stored.clone();
|
||||
other.host_id = Some("h2".into());
|
||||
assert!(validate_aws_update(&stored, &other, &hosts).is_err());
|
||||
let mut moved = stored.clone();
|
||||
moved.aws_placement.as_mut().unwrap().environment = "prod".into();
|
||||
assert!(validate_aws_update(&stored, &moved, &hosts).is_err());
|
||||
for set in [
|
||||
|a: &mut crate::models::AwsWorkspaceConfig| a.task_arn = Some("t".into()),
|
||||
|a: &mut crate::models::AwsWorkspaceConfig| a.task_definition_arn = Some("td".into()),
|
||||
] {
|
||||
let mut st = aws_project();
|
||||
set(st.aws.as_mut().unwrap());
|
||||
assert!(validate_aws_update(&st, &local, &hosts).is_err());
|
||||
}
|
||||
// No handles: free to move. Other edits are fine with handles.
|
||||
assert!(validate_aws_update(&aws_project(), &local, &hosts).is_ok());
|
||||
let mut edited = stored.clone();
|
||||
edited.aws.as_mut().unwrap().cpu = 2048;
|
||||
edited.aws.as_mut().unwrap().memory_mib = 8192;
|
||||
assert!(validate_aws_update(&stored, &edited, &hosts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_only_operations_refuse_aws_projects() {
|
||||
assert!(refuse_aws(&aws_project(), "Reset").unwrap_err().contains("Reset is not available"));
|
||||
let mut local = aws_project();
|
||||
local.host_id = None;
|
||||
assert!(refuse_aws(&local, "Reset").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_start_with_a_task_left_running_is_not_stopped() {
|
||||
let mut p = aws_project();
|
||||
assert!(!aws_task_left_after_failed_start(&p));
|
||||
p.aws.as_mut().unwrap().task_arn = Some("arn:aws:ecs:r:a:task/c/t".into());
|
||||
assert!(aws_task_left_after_failed_start(&p));
|
||||
let mut local = p.clone();
|
||||
local.host_id = None;
|
||||
assert!(!aws_task_left_after_failed_start(&local));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_without_the_connection_needs_explicit_confirmation() {
|
||||
let e = missing_connection_removal(false).unwrap_err();
|
||||
assert!(e.contains("still be running") && e.contains("Settings → Hosts"), "{e}");
|
||||
assert!(e.starts_with(AWS_CONNECTION_MISSING));
|
||||
assert!(missing_connection_removal(true).is_ok());
|
||||
// The frontend keys its second confirm on the same prefix.
|
||||
let ts = include_str!("../../../src/lib/types.ts");
|
||||
assert!(
|
||||
ts.contains(&format!("export const AWS_CONNECTION_MISSING = {:?};", AWS_CONNECTION_MISSING)),
|
||||
"lib/types.ts must mirror AWS_CONNECTION_MISSING"
|
||||
);
|
||||
}
|
||||
|
||||
fn stop_store(status: ProjectStatus) -> (tempfile::TempDir, crate::storage::projects_store::ProjectsStore, String) {
|
||||
let mut p = aws_project();
|
||||
p.status = status;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = crate::storage::projects_store::ProjectsStore::over_file_for_test(
|
||||
dir.path().join("projects.json"),
|
||||
vec![p.clone()],
|
||||
);
|
||||
(dir, store, p.id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_push_before_stop_puts_the_status_back_and_records_nothing() {
|
||||
use crate::commands::stop_guard::tests::Fake;
|
||||
let (_d, store, id) = stop_store(ProjectStatus::Running);
|
||||
store.update_status(&id, ProjectStatus::Stopping).unwrap();
|
||||
let fake = Fake::new(Ok(("push to origin failed: denied".into(), 1)));
|
||||
let e = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.contains("denied"), "{e}");
|
||||
assert!(e.contains("terminals were closed"), "{e}");
|
||||
assert_eq!(store.get(&id).unwrap().status, ProjectStatus::Running);
|
||||
assert!(fake.markers.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_successful_push_before_stop_records_it_and_leaves_the_stop_going() {
|
||||
use crate::commands::stop_guard::tests::Fake;
|
||||
let (_d, store, id) = stop_store(ProjectStatus::Running);
|
||||
store.update_status(&id, ProjectStatus::Stopping).unwrap();
|
||||
let fake = Fake::new(Ok(("pushed".into(), 0)));
|
||||
let note = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(note, None);
|
||||
assert_eq!(store.get(&id).unwrap().status, ProjectStatus::Stopping, "untouched: the stop goes on");
|
||||
assert_eq!(*fake.markers.lock().unwrap(), vec!["pushed".to_string()]);
|
||||
assert_eq!(fake.calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_terminals_close_before_the_stop_guard_pushes_or_records_anything() {
|
||||
use crate::commands::stop_guard::tests::Fake;
|
||||
for wip in [Some(WipChoice::Push), Some(WipChoice::Discard), None] {
|
||||
let (_d, store, id) = stop_store(ProjectStatus::Running);
|
||||
let fake = Fake::new(Ok(("pushed".into(), 0)));
|
||||
let seen_at_close = std::sync::Mutex::new(None);
|
||||
aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), wip, std::time::Duration::from_secs(5), async {
|
||||
*seen_at_close.lock().unwrap() =
|
||||
Some((fake.calls.lock().unwrap().len(), fake.markers.lock().unwrap().len()));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*seen_at_close.lock().unwrap(), Some((0, 0)), "{wip:?}: closed first");
|
||||
}
|
||||
// Stop's own code closes them through that step, not after it.
|
||||
let src = include_str!("project_commands.rs");
|
||||
let stop = &src[src.find("pub async fn stop_project_container").unwrap()..];
|
||||
let stop = &stop[..stop.find("\n}\n").unwrap()];
|
||||
let close = stop.find("close_sessions_for_container").unwrap();
|
||||
assert!(close < stop.find("aws_guard_before_stop(").unwrap());
|
||||
assert!(stop.find("auth_bridge.stop").unwrap() < stop.find("aws_guard_before_stop(").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_aws_project_takes_repos_not_folders_and_no_runtime_handles() {
|
||||
let hosts = vec![host("h1")];
|
||||
let place = || Some(AwsPlacement { region: "us-west-2".into(), environment: "dev".into() });
|
||||
let cfg = AwsWorkspaceConfig {
|
||||
task_arn: Some("arn:forged".into()),
|
||||
access_point_id: Some("fsap-forged".into()),
|
||||
repos: vec![crate::models::RepoSource { url: "https://h/r.git".into(), branch: None, folder: "r".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
let p = new_project("a".into(), vec![], Some("h1".into()), place(), Some(cfg.clone()), &hosts).unwrap();
|
||||
assert!(p.is_aws() && p.paths.is_empty());
|
||||
let a = p.aws.unwrap();
|
||||
assert_eq!((a.task_arn, a.access_point_id, a.task_definition_arn), (None, None, None));
|
||||
assert_eq!(a.repos.len(), 1);
|
||||
// folders are refused, so are half placements, unknown hosts and invalid configs
|
||||
let folder = vec![ProjectPath { host_path: "/x".into(), mount_name: "x".into() }];
|
||||
assert!(new_project("a".into(), folder, Some("h1".into()), place(), Some(cfg.clone()), &hosts).is_err());
|
||||
assert!(new_project("a".into(), vec![], Some("h1".into()), None, Some(cfg.clone()), &hosts).is_err());
|
||||
assert!(new_project("a".into(), vec![], Some("nope".into()), place(), Some(cfg.clone()), &hosts).is_err());
|
||||
let bad = AwsWorkspaceConfig { cpu: 256, memory_mib: 8192, ..Default::default() };
|
||||
let e = new_project("a".into(), vec![], Some("h1".into()), place(), Some(bad), &hosts).unwrap_err();
|
||||
assert!(e.contains("not valid"), "{e}");
|
||||
// a local project still needs a folder
|
||||
assert!(new_project("a".into(), vec![], None, None, None, &hosts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_resources_are_removable_only_from_a_stopped_aws_project() {
|
||||
let mut p = aws_project();
|
||||
assert!(check_aws_resources_removable(&p).is_ok());
|
||||
p.status = ProjectStatus::Running;
|
||||
assert!(check_aws_resources_removable(&p).unwrap_err().contains("Stop"));
|
||||
p.status = ProjectStatus::Error;
|
||||
assert!(check_aws_resources_removable(&p).is_err());
|
||||
let mut local = aws_project();
|
||||
local.host_id = None;
|
||||
assert!(check_aws_resources_removable(&local).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
//! The AWS stop guard: what `triple-c-wip-push` says about a workspace's
|
||||
//! unsaved git work, and the push a Stop may run first.
|
||||
//!
|
||||
//! The decisions are made over [`WipRunner`] so a fake target exercises them;
|
||||
//! [`ContainerTarget`] runs the script through the agent as `claude` (the
|
||||
//! script needs the user's credential helper and `~/.ssh`).
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{RepoState, UnsavedWork, WipChoice};
|
||||
|
||||
/// The script, baked into the image (`container/triple-c-wip-push`).
|
||||
pub const WIP_PUSH: &str = "/usr/local/bin/triple-c-wip-push";
|
||||
/// A dry run only reads `git status`; it should be quick.
|
||||
pub const DRY_RUN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// A push goes over the network, once per dirty repo.
|
||||
pub const PUSH_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// Cap on the push output carried into an error.
|
||||
const MAX_OUTPUT: usize = 4000;
|
||||
/// How much sooner than the client's timeout the in-container `timeout`
|
||||
/// ends the script (its TERM, then 5 s to the KILL): an exec the client gives
|
||||
/// up on is not killed by the agent, so the container bounds it itself.
|
||||
const IN_CONTAINER_MARGIN: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Where the entrypoint's stop hook looks for the app's decision (Task 7's
|
||||
/// file, which the idle watcher also writes `idle` to).
|
||||
pub const STOP_REASON_FILE: &str = "/run/triple-c/stop-reason";
|
||||
/// Recorded after the guard's push succeeded: the hook does not push again.
|
||||
pub const MARKER_PUSHED: &str = "pushed";
|
||||
/// Recorded for "Stop without pushing": the hook does not push.
|
||||
pub const MARKER_DISCARD: &str = "discard";
|
||||
|
||||
/// The workspace, as the stop guard uses it.
|
||||
pub trait WipRunner: Sync {
|
||||
/// Run the WIP script as `claude`: `(combined output, exit code)`. The
|
||||
/// caller waits at most `limit`; the run must not outlive it.
|
||||
fn run(
|
||||
&self,
|
||||
args: Vec<String>,
|
||||
limit: Duration,
|
||||
) -> impl Future<Output = Result<(String, i64), String>> + Send;
|
||||
/// Record `marker` in [`STOP_REASON_FILE`] (as root: the directory is root's).
|
||||
fn write_stop_reason(&self, marker: &str) -> impl Future<Output = Result<(), String>> + Send;
|
||||
/// Remove [`STOP_REASON_FILE`] (as root), so the hook pushes as configured again.
|
||||
fn clear_stop_reason(&self) -> impl Future<Output = Result<(), String>> + Send;
|
||||
}
|
||||
|
||||
impl WipRunner for ContainerTarget {
|
||||
async fn run(&self, args: Vec<String>, limit: Duration) -> Result<(String, i64), String> {
|
||||
self.exec_oneshot_as("claude", wip_command(args, limit), Vec::new()).await
|
||||
}
|
||||
|
||||
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
|
||||
let (out, code) = self
|
||||
.exec_oneshot_as("root", stop_reason_command(marker), Vec::new())
|
||||
.await?;
|
||||
if code == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("exit {code}: {}", cap(&out)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_stop_reason(&self) -> Result<(), String> {
|
||||
let (out, code) = self
|
||||
.exec_oneshot_as("root", clear_stop_reason_command(), Vec::new())
|
||||
.await?;
|
||||
if code == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("exit {code}: {}", cap(&out)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The script's argv under coreutils `timeout`, ending it
|
||||
/// [`IN_CONTAINER_MARGIN`] before the client's `limit` (KILL 5 s after the
|
||||
/// TERM), so a push the app gave up on is not left running in the workspace.
|
||||
pub fn wip_command(args: Vec<String>, limit: Duration) -> Vec<String> {
|
||||
let secs = limit.saturating_sub(IN_CONTAINER_MARGIN).as_secs().max(1);
|
||||
let mut cmd = vec![
|
||||
"/usr/bin/timeout".to_string(),
|
||||
"-k".to_string(),
|
||||
"5".to_string(),
|
||||
secs.to_string(),
|
||||
WIP_PUSH.to_string(),
|
||||
];
|
||||
cmd.extend(args);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The root command that forgets a recorded marker (a fixed argv).
|
||||
pub fn clear_stop_reason_command() -> Vec<String> {
|
||||
vec!["rm".into(), "-f".into(), STOP_REASON_FILE.into()]
|
||||
}
|
||||
|
||||
/// After StopTask failed: the workspace keeps running, so a marker this stop
|
||||
/// recorded must not suppress the push of a later stop (Spot, ECS, idle) the
|
||||
/// user never chose for. Best effort; logged.
|
||||
pub async fn forget_choice<R: WipRunner>(runner: Option<&R>, choice: Option<WipChoice>) {
|
||||
if choice.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = runner else {
|
||||
log::warn!("Stop failed and the workspace is unreachable; its stop marker may remain");
|
||||
return;
|
||||
};
|
||||
match tokio::time::timeout(DRY_RUN_TIMEOUT, r.clear_stop_reason()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => log::warn!("Could not remove the stop marker after a failed stop: {e}"),
|
||||
Err(_) => log::warn!("Removing the stop marker after a failed stop timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The root command that records `marker`; the value travels as `$1`, never
|
||||
/// inside the script.
|
||||
pub fn stop_reason_command(marker: &str) -> Vec<String> {
|
||||
vec![
|
||||
"sh".into(),
|
||||
"-c".into(),
|
||||
format!(
|
||||
"umask 077 && mkdir -p /run/triple-c && chmod 700 /run/triple-c && printf '%s\\n' \"$1\" > {STOP_REASON_FILE}"
|
||||
),
|
||||
"sh".into(),
|
||||
marker.into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The repository lines of a dry run. Lines that are not JSON objects (a
|
||||
/// warning on stderr) are skipped; a JSON line of the wrong shape is an error.
|
||||
pub fn parse_dry_run(output: &str) -> Result<Vec<RepoState>, String> {
|
||||
output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.map(|l| {
|
||||
serde_json::from_str::<RepoState>(l)
|
||||
.map_err(|e| format!("Unexpected output from the WIP check: {e}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unchecked(reason: String) -> UnsavedWork {
|
||||
UnsavedWork { checked: false, repos: Vec::new(), error: Some(reason) }
|
||||
}
|
||||
|
||||
/// Ask the workspace what a stop would lose. `runner` is `None` when the
|
||||
/// workspace's agent is not reachable (no tunnel).
|
||||
pub async fn unsaved_work<R: WipRunner>(runner: Option<&R>, timeout: Duration) -> UnsavedWork {
|
||||
let Some(runner) = runner else {
|
||||
return unchecked(
|
||||
"Triple-C is not connected to the workspace, so it can't check for unsaved work."
|
||||
.into(),
|
||||
);
|
||||
};
|
||||
let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()], timeout)).await;
|
||||
match res {
|
||||
Err(_) => unchecked("The workspace did not answer the check for unsaved work in time.".into()),
|
||||
Ok(Err(e)) => unchecked(format!("The workspace could not be asked about unsaved work: {e}")),
|
||||
Ok(Ok((out, code))) if code != 0 => unchecked(format!(
|
||||
"The check for unsaved work failed (exit {code}): {}",
|
||||
cap(&out)
|
||||
)),
|
||||
Ok(Ok((out, _))) => match parse_dry_run(&out) {
|
||||
Ok(repos) => UnsavedWork { checked: true, repos, error: None },
|
||||
Err(e) => unchecked(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn cap(s: &str) -> String {
|
||||
let t = s.trim();
|
||||
if t.len() <= MAX_OUTPUT {
|
||||
return t.to_string();
|
||||
}
|
||||
let mut end = t.len() - MAX_OUTPUT;
|
||||
while !t.is_char_boundary(end) {
|
||||
end += 1;
|
||||
}
|
||||
format!("…{}", &t[end..])
|
||||
}
|
||||
|
||||
/// What Stop does before stopping, given the user's choice. `Push` runs the
|
||||
/// push and fails (aborting the stop) with its output when it does not
|
||||
/// succeed; `Discard` and no choice do nothing.
|
||||
pub async fn before_stop<R: WipRunner>(
|
||||
runner: Option<&R>,
|
||||
choice: Option<WipChoice>,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
if choice != Some(WipChoice::Push) {
|
||||
return Ok(());
|
||||
}
|
||||
let runner = runner.ok_or_else(|| {
|
||||
"Triple-C is not connected to the workspace, so it can't push unsaved work. The \
|
||||
workspace was not stopped."
|
||||
.to_string()
|
||||
})?;
|
||||
let res =
|
||||
tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()], timeout)).await;
|
||||
match res {
|
||||
Err(_) => Err("Pushing unsaved work did not finish in time. The workspace was not stopped.".into()),
|
||||
Ok(Err(e)) => Err(format!("Could not push unsaved work: {e}. The workspace was not stopped.")),
|
||||
Ok(Ok((out, 0))) => {
|
||||
log::info!("WIP push before stop: {}", cap(&out));
|
||||
Ok(())
|
||||
}
|
||||
Ok(Ok((out, code))) => Err(format!(
|
||||
"Pushing unsaved work failed (exit {code}), so the workspace was not stopped:\n{}",
|
||||
cap(&out)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The note a stop returns when the hook's own push may still run.
|
||||
pub const MARKER_NOT_RECORDED: &str = "Triple-C could not tell the workspace what you chose, so its own \
|
||||
push of work in progress on shutdown may still run (if it is turned on).";
|
||||
|
||||
/// Everything the guard does before StopTask: `Push` pushes (a failure is an
|
||||
/// `Err` and the stop must not happen), then records `pushed`; `Discard`
|
||||
/// records `discard`; no choice does nothing. A marker that can't be
|
||||
/// written does not stop the stop: `Ok(Some(note))` says the hook may push.
|
||||
pub async fn prepare_stop<R: WipRunner>(
|
||||
runner: Option<&R>,
|
||||
choice: Option<WipChoice>,
|
||||
push_timeout: Duration,
|
||||
) -> Result<Option<String>, String> {
|
||||
let marker = match choice {
|
||||
None => return Ok(None),
|
||||
Some(WipChoice::Push) => {
|
||||
before_stop(runner, choice, push_timeout).await?;
|
||||
MARKER_PUSHED
|
||||
}
|
||||
Some(WipChoice::Discard) => MARKER_DISCARD,
|
||||
};
|
||||
let written = match runner {
|
||||
Some(r) => tokio::time::timeout(DRY_RUN_TIMEOUT, r.write_stop_reason(marker))
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("timed out".into())),
|
||||
None => Err("not connected to the workspace".into()),
|
||||
};
|
||||
match written {
|
||||
Ok(()) => Ok(None),
|
||||
Err(e) => {
|
||||
log::warn!("Could not record the stop guard's choice ({marker}): {e}");
|
||||
Ok(Some(MARKER_NOT_RECORDED.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub(crate) struct Fake {
|
||||
pub reply: Result<(String, i64), String>,
|
||||
pub calls: Mutex<Vec<Vec<String>>>,
|
||||
pub hang: bool,
|
||||
pub markers: Mutex<Vec<String>>,
|
||||
pub marker_fails: bool,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
pub fn new(reply: Result<(String, i64), String>) -> Self {
|
||||
Fake { reply, calls: Mutex::new(Vec::new()), hang: false, markers: Mutex::new(Vec::new()), marker_fails: false }
|
||||
}
|
||||
fn markers(&self) -> Vec<String> {
|
||||
self.markers.lock().unwrap().clone()
|
||||
}
|
||||
fn calls(&self) -> Vec<Vec<String>> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl WipRunner for Fake {
|
||||
async fn run(&self, args: Vec<String>, _limit: Duration) -> Result<(String, i64), String> {
|
||||
self.calls.lock().unwrap().push(args);
|
||||
if self.hang {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
self.reply.clone()
|
||||
}
|
||||
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
|
||||
if self.marker_fails {
|
||||
return Err("agent unreachable".into());
|
||||
}
|
||||
self.markers.lock().unwrap().push(marker.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn clear_stop_reason(&self) -> Result<(), String> {
|
||||
self.markers.lock().unwrap().push("<cleared>".to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const T: Duration = Duration::from_secs(5);
|
||||
|
||||
#[test]
|
||||
fn a_dry_run_is_parsed_skipping_non_json_lines() {
|
||||
let out = "warning: something\n{\"folder\":\"api\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}\n\
|
||||
{\"folder\":\"web\",\"dirty\":false,\"unpushed\":2,\"branch\":\"HEAD\"}\n";
|
||||
let repos = parse_dry_run(out).unwrap();
|
||||
assert_eq!(repos.len(), 2);
|
||||
assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into(), cloning: false });
|
||||
assert_eq!(repos[1].unpushed, 2);
|
||||
assert!(parse_dry_run("{\"folder\":1}").is_err());
|
||||
assert!(parse_dry_run("").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_check_runs_a_dry_run_and_reports_the_repos() {
|
||||
let fake = Fake::new(Ok(("{\"folder\":\"a\",\"dirty\":false,\"unpushed\":0,\"branch\":\"main\"}".into(), 0)));
|
||||
let w = unsaved_work(Some(&fake), T).await;
|
||||
assert!(w.checked && w.error.is_none());
|
||||
assert_eq!(w.repos.len(), 1);
|
||||
assert_eq!(fake.calls(), vec![vec!["--dry-run".to_string()]]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_workspace_is_unchecked_with_a_reason() {
|
||||
let w = unsaved_work::<Fake>(None, T).await;
|
||||
assert!(!w.checked);
|
||||
assert!(w.error.as_deref().unwrap().contains("not connected"));
|
||||
|
||||
let failing = Fake::new(Err("connection refused".into()));
|
||||
let w = unsaved_work(Some(&failing), T).await;
|
||||
assert!(!w.checked && w.error.unwrap().contains("connection refused"));
|
||||
|
||||
let nonzero = Fake::new(Ok(("boom".into(), 2)));
|
||||
let w = unsaved_work(Some(&nonzero), T).await;
|
||||
assert!(!w.checked && w.error.unwrap().contains("exit 2"));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_check_that_hangs_times_out_unchecked() {
|
||||
let mut fake = Fake::new(Ok((String::new(), 0)));
|
||||
fake.hang = true;
|
||||
let w = unsaved_work(Some(&fake), Duration::from_secs(1)).await;
|
||||
assert!(!w.checked && w.error.unwrap().contains("in time"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_runs_the_script_with_reason_stop() {
|
||||
let fake = Fake::new(Ok(("pushed".into(), 0)));
|
||||
before_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap();
|
||||
assert_eq!(fake.calls(), vec![vec!["--reason".to_string(), "stop".to_string()]]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_push_aborts_with_its_output() {
|
||||
let fake = Fake::new(Ok(("triple-c-wip-push: api: push to origin failed: denied".into(), 1)));
|
||||
let e = before_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap_err();
|
||||
assert!(e.contains("push to origin failed: denied"), "{e}");
|
||||
assert!(e.contains("not stopped"));
|
||||
|
||||
let gone = Fake::new(Err("tunnel closed".into()));
|
||||
assert!(before_stop(Some(&gone), Some(WipChoice::Push), T).await.unwrap_err().contains("tunnel closed"));
|
||||
assert!(before_stop::<Fake>(None, Some(WipChoice::Push), T).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discard_or_no_choice_runs_nothing() {
|
||||
let fake = Fake::new(Err("must not run".into()));
|
||||
before_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap();
|
||||
before_stop(Some(&fake), None, T).await.unwrap();
|
||||
before_stop::<Fake>(None, None, T).await.unwrap();
|
||||
assert!(fake.calls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_is_bounded_inside_the_workspace_before_the_client_gives_up() {
|
||||
let cmd = wip_command(vec!["--reason".into(), "stop".into()], PUSH_TIMEOUT);
|
||||
assert_eq!(cmd, vec!["/usr/bin/timeout", "-k", "5", "290", WIP_PUSH, "--reason", "stop"]);
|
||||
let secs: u64 = cmd[3].parse().unwrap();
|
||||
assert!(secs + 5 < PUSH_TIMEOUT.as_secs(), "TERM and KILL land before the client's timeout");
|
||||
let dry = wip_command(vec!["--dry-run".into()], DRY_RUN_TIMEOUT);
|
||||
assert_eq!(&dry[3..], &["20", WIP_PUSH, "--dry-run"]);
|
||||
assert_eq!(wip_command(Vec::new(), Duration::from_secs(3))[3], "1", "never 0 (no limit)");
|
||||
// The image has coreutils' timeout where the argv says.
|
||||
let df = include_str!("../../../../container/Dockerfile");
|
||||
assert!(df.contains("ubuntu:24.04") || df.contains("ubuntu:noble"), "coreutils at /usr/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cloning_repo_parses_with_its_flag_and_older_lines_without_it() {
|
||||
let out = "{\"folder\":\"half\",\"dirty\":false,\"unpushed\":0,\"branch\":\"\",\"cloning\":true}\n\
|
||||
{\"folder\":\"a\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}";
|
||||
let repos = parse_dry_run(out).unwrap();
|
||||
assert!(repos[0].cloning && !repos[1].cloning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_keeps_its_end() {
|
||||
let s = format!("{}END", "x".repeat(10_000));
|
||||
let c = cap(&s);
|
||||
assert!(c.ends_with("END") && c.len() < 4100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_successful_push_records_pushed_and_stop_without_pushing_records_discard() {
|
||||
let fake = Fake::new(Ok(("pushed".into(), 0)));
|
||||
assert_eq!(prepare_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap(), None);
|
||||
assert_eq!(fake.markers(), vec![MARKER_PUSHED]);
|
||||
|
||||
let fake = Fake::new(Err("must not run".into()));
|
||||
assert_eq!(prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap(), None);
|
||||
assert!(fake.calls().is_empty());
|
||||
assert_eq!(fake.markers(), vec![MARKER_DISCARD]);
|
||||
|
||||
assert_eq!(prepare_stop(Some(&fake), None, T).await.unwrap(), None);
|
||||
assert_eq!(fake.markers().len(), 1, "no choice records nothing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_push_records_nothing() {
|
||||
let fake = Fake::new(Ok(("denied".into(), 1)));
|
||||
assert!(prepare_stop(Some(&fake), Some(WipChoice::Push), T).await.is_err());
|
||||
assert!(fake.markers().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unwritable_marker_still_stops_and_says_the_hook_may_push() {
|
||||
let mut fake = Fake::new(Ok((String::new(), 0)));
|
||||
fake.marker_fails = true;
|
||||
let note = prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap();
|
||||
assert!(note.unwrap().contains("may still run"));
|
||||
let note = prepare_stop::<Fake>(None, Some(WipChoice::Discard), T).await.unwrap();
|
||||
assert!(note.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marker_is_written_as_an_argument_to_the_file_the_hook_reads() {
|
||||
let cmd = stop_reason_command("pushed");
|
||||
assert_eq!(&cmd[..2], &["sh".to_string(), "-c".to_string()]);
|
||||
assert!(cmd[2].contains("> /run/triple-c/stop-reason") && cmd[2].contains("\"$1\""));
|
||||
assert_eq!(cmd.last().unwrap(), "pushed");
|
||||
// The entrypoint matches exactly these values.
|
||||
let ep = include_str!("../../../../container/entrypoint.sh");
|
||||
assert!(ep.contains("pushed|discard)") && ep.contains(STOP_REASON_FILE));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_stop_forgets_the_recorded_choice() {
|
||||
let fake = Fake::new(Ok((String::new(), 0)));
|
||||
prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap();
|
||||
forget_choice(Some(&fake), Some(WipChoice::Discard)).await;
|
||||
assert_eq!(fake.markers(), vec![MARKER_DISCARD, "<cleared>"]);
|
||||
// An unguarded stop recorded nothing, so there is nothing to forget.
|
||||
let fake = Fake::new(Ok((String::new(), 0)));
|
||||
forget_choice(Some(&fake), None).await;
|
||||
assert!(fake.markers().is_empty());
|
||||
forget_choice::<Fake>(None, Some(WipChoice::Push)).await;
|
||||
assert_eq!(clear_stop_reason_command(), vec!["rm", "-f", "/run/triple-c/stop-reason"]);
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,9 @@ pub async fn open_terminal_session(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
// Waits while an AWS project's tunnel is being re-opened after a restart.
|
||||
let target = ContainerTarget::for_project_waiting(&project)
|
||||
.await
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
if let Some(id) = resume_session_id.as_deref() {
|
||||
|
||||
@@ -32,6 +32,7 @@ use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::aws::tasks::StopKind;
|
||||
use crate::host::watch::ExitSource;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Project, ProjectStatus};
|
||||
@@ -68,6 +69,32 @@ pub enum ExitReason {
|
||||
pub struct ContainerExited<'a> {
|
||||
pub project: &'a Project,
|
||||
pub reason: ExitReason,
|
||||
/// AWS: why ECS says the task stopped (P8). `None` on Docker, or when
|
||||
/// the watch has no reason on record.
|
||||
pub stop: Option<StopNote>,
|
||||
}
|
||||
|
||||
/// Why an AWS task stopped, for the UI's toast.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct StopNote {
|
||||
/// `spot`, `idle`, `failed` (the project goes to Error), `user` (someone
|
||||
/// stopped it: no toast) or `exited` (on its own).
|
||||
pub kind: &'static str,
|
||||
/// ECS's stopped reason, or a fixed text.
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl StopNote {
|
||||
pub fn of(kind: &StopKind) -> StopNote {
|
||||
let (kind, message) = match kind {
|
||||
StopKind::Spot => ("spot", Some("Spot interruption".to_string())),
|
||||
StopKind::Idle => ("idle", None),
|
||||
StopKind::FailedToStart(r) => ("failed", Some(r.clone())),
|
||||
StopKind::UserInitiated => ("user", None),
|
||||
StopKind::Exited(r) => ("exited", r.clone()),
|
||||
};
|
||||
StopNote { kind, message }
|
||||
}
|
||||
}
|
||||
|
||||
fn project_busy(project_id: &str) -> bool {
|
||||
@@ -143,11 +170,11 @@ async fn unless_cancelled<T>(
|
||||
/// (see [`STABLE_AFTER`]).
|
||||
async fn watch_once(
|
||||
store: &ProjectsStore,
|
||||
source: &ExitSource,
|
||||
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);
|
||||
}
|
||||
@@ -187,6 +214,7 @@ async fn watch_once(
|
||||
/// test can drive it without a Tauri app.
|
||||
pub(crate) async fn run(
|
||||
store: Arc<ProjectsStore>,
|
||||
source: ExitSource,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
on_change: impl Fn(&Project, ExitReason),
|
||||
) {
|
||||
@@ -197,7 +225,7 @@ pub(crate) async fn run(
|
||||
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 {
|
||||
let Some(healthy) = watch_once(&store, &source, &mut cancel, sweep_reason, &on_change).await else {
|
||||
return;
|
||||
};
|
||||
sweep_reason = ExitReason::Exited;
|
||||
@@ -209,21 +237,59 @@ pub(crate) async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the watcher for the life of the app.
|
||||
/// Start a watcher on `source` for the life of the app. One per host kind:
|
||||
/// Docker's event stream, and (Task 6) the AWS task poll.
|
||||
pub fn spawn(
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
source: ExitSource,
|
||||
cancel: watch::Receiver<bool>,
|
||||
) -> tauri::async_runtime::JoinHandle<()> {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run(store, cancel, |p, reason| {
|
||||
let store_for_aws = store.clone();
|
||||
run(store, source, cancel, move |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 });
|
||||
// Why ECS stopped it, read before the tunnel (and with it the
|
||||
// registry's record) goes.
|
||||
let kind = p
|
||||
.is_aws()
|
||||
.then(|| p.container_id.as_deref().and_then(crate::host::tunnels::stop_kind))
|
||||
.flatten();
|
||||
let mut shown = p.clone();
|
||||
// A task that never got going is an error, not a stop (P8).
|
||||
if let Some(StopKind::FailedToStart(_)) = kind {
|
||||
if store_for_aws.update_status(&p.id, ProjectStatus::Error).is_ok() {
|
||||
shown.status = ProjectStatus::Error;
|
||||
}
|
||||
}
|
||||
// An AWS task that stopped: its tunnel leads nowhere now, and its
|
||||
// task ARN is history (the data's access point stays).
|
||||
if p.is_aws() {
|
||||
if let Some(a) = p.aws.as_ref() {
|
||||
let _ = store_for_aws.set_aws_runtime(
|
||||
&p.id,
|
||||
None,
|
||||
a.access_point_id.clone(),
|
||||
a.task_definition_arn.clone(),
|
||||
);
|
||||
}
|
||||
let id = p.id.clone();
|
||||
if let Some(arn) = p.aws.as_ref().and_then(|a| a.task_arn.clone()) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::host::tunnels::close_if_task(&id, &arn).await
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(store_p) = store_for_aws.get(&p.id) {
|
||||
shown = store_p;
|
||||
}
|
||||
let stop = kind.as_ref().map(StopNote::of);
|
||||
let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: &shown, reason, stop });
|
||||
})
|
||||
.await;
|
||||
})
|
||||
@@ -244,15 +310,38 @@ mod tests {
|
||||
#[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();
|
||||
let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::Exited, stop: None })
|
||||
.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!(v["stop"].is_null());
|
||||
let v = serde_json::to_value(ContainerExited {
|
||||
project: &p,
|
||||
reason: ExitReason::FoundStopped,
|
||||
stop: Some(StopNote::of(&StopKind::Spot)),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v["reason"], "found_stopped");
|
||||
assert_eq!(v["stop"], serde_json::json!({ "kind": "spot", "message": "Spot interruption" }));
|
||||
assert_eq!(CONTAINER_EXITED_EVENT, "project-container-exited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_stop_kind_has_a_note_for_the_ui() {
|
||||
let cases = [
|
||||
(StopKind::Spot, "spot", Some("Spot interruption")),
|
||||
(StopKind::Idle, "idle", None),
|
||||
(StopKind::FailedToStart("CannotPull".into()), "failed", Some("CannotPull")),
|
||||
(StopKind::UserInitiated, "user", None),
|
||||
(StopKind::Exited(Some("OOM".into())), "exited", Some("OOM")),
|
||||
(StopKind::Exited(None), "exited", None),
|
||||
];
|
||||
for (k, kind, message) in cases {
|
||||
let n = StopNote::of(&k);
|
||||
assert_eq!((n.kind, n.message.as_deref()), (kind, message), "{k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The shutdown path: a host call that never answers (a wedged daemon)
|
||||
/// must give way to cancellation at once, not hold teardown.
|
||||
#[tokio::test]
|
||||
@@ -270,6 +359,48 @@ mod tests {
|
||||
assert_eq!(unless_cancelled(&mut rx, async { 1 }).await, None);
|
||||
}
|
||||
|
||||
/// The AWS source drives the same loop: it is "reachable" without a
|
||||
/// ping, polls its lookup every interval, and gives way to cancellation.
|
||||
/// No project is Running, so nothing is confirmed (and nothing reaches
|
||||
/// Docker or AWS).
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn an_aws_source_polls_its_lookup_until_cancelled() {
|
||||
use crate::host::watch::{AwsTaskLookup, AwsWatch, TaskGroup, WatchedTask};
|
||||
use futures_util::future::BoxFuture;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct Lookup(AtomicUsize);
|
||||
impl AwsTaskLookup for Lookup {
|
||||
fn running_tasks(&self) -> Vec<WatchedTask> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Vec::new()
|
||||
}
|
||||
fn stopped<'a>(
|
||||
&'a self,
|
||||
_: &'a TaskGroup,
|
||||
_: &'a [String],
|
||||
) -> BoxFuture<'a, Result<Vec<String>, String>> {
|
||||
Box::pin(async { Ok(Vec::new()) })
|
||||
}
|
||||
}
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("triple-c-watch-aws-{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let store = Arc::new(ProjectsStore::over_file_for_test(dir.join("projects.json"), Vec::new()));
|
||||
let lookup = Arc::new(Lookup(AtomicUsize::new(0)));
|
||||
let source = ExitSource::Aws(AwsWatch::every(lookup.clone(), Duration::from_secs(15)));
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let watcher = tokio::spawn(run(store, source, cancel_rx, |_, _| panic!("nothing stopped")));
|
||||
tokio::time::sleep(Duration::from_secs(46)).await;
|
||||
assert_eq!(lookup.0.load(Ordering::SeqCst), 3);
|
||||
let _ = cancel_tx.send(true);
|
||||
tokio::time::timeout(Duration::from_secs(1), watcher)
|
||||
.await
|
||||
.expect("stops at once")
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -302,7 +433,7 @@ mod tests {
|
||||
|
||||
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 watcher = tokio::spawn(run(store.clone(), ExitSource::Docker, 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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ use sha2::{Sha256, Digest};
|
||||
|
||||
use super::ca_certs;
|
||||
use super::client::get_docker;
|
||||
use super::workspace_env::{workspace_env, WorkspaceEnvInputs};
|
||||
use crate::models::AppSettings;
|
||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
@@ -139,7 +140,7 @@ The user can click a file path in this terminal to open it in a viewer (while a
|
||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||
/// the same value is produced in both paths.
|
||||
fn build_claude_instructions(
|
||||
pub(super) fn build_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
port_mappings: &[PortMapping],
|
||||
@@ -244,7 +245,11 @@ pub(crate) const LABEL_SCRUB: &str = "triple-c.scrub";
|
||||
/// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile.
|
||||
pub(crate) const LABEL_BASE: &str = "triple-c.base";
|
||||
|
||||
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||
/// `TRIPLEC_` is the AWS workspace's own namespace (`TRIPLEC_HOST`,
|
||||
/// `TRIPLEC_FILES`, `TRIPLEC_AGENT_TOKEN`, … — see `container/entrypoint.sh`):
|
||||
/// a custom var there could switch a local container into AWS mode or forge
|
||||
/// the inputs the entrypoint trusts.
|
||||
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_", "TRIPLEC_"];
|
||||
|
||||
/// Exact env var names Triple-C manages itself. Not covered by
|
||||
/// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes.
|
||||
@@ -356,7 +361,7 @@ fn model_alias_fingerprint_part(
|
||||
}
|
||||
|
||||
/// Whether `key` is an env var name Triple-C reserves for itself.
|
||||
fn is_reserved_env_key(key: &str) -> bool {
|
||||
pub(super) fn is_reserved_env_key(key: &str) -> bool {
|
||||
let upper = key.to_uppercase();
|
||||
RESERVED_ENV_PREFIXES.iter().any(|p| upper.starts_with(p))
|
||||
|| RESERVED_ENV_EXACT.iter().any(|e| upper == *e)
|
||||
@@ -375,7 +380,7 @@ fn is_reserved_env_key(key: &str) -> bool {
|
||||
///
|
||||
/// Empty stays empty rather than becoming the hash of the empty string: an empty
|
||||
/// label is how every other `triple-c.*` key says "nothing configured".
|
||||
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
pub(super) fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for env_var in custom_env_vars {
|
||||
let key = env_var.key.trim();
|
||||
@@ -401,7 +406,7 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
///
|
||||
/// The returned token is never logged and never leaves this module except as
|
||||
/// the env var value handed to Docker.
|
||||
fn shared_claude_auth(project: &Project) -> Option<(String, String)> {
|
||||
pub(super) fn shared_claude_auth(project: &Project) -> Option<(String, String)> {
|
||||
if project.backend != Backend::Anthropic || !project.use_shared_auth_token {
|
||||
return None;
|
||||
}
|
||||
@@ -432,7 +437,7 @@ fn claude_token_label(project: &Project) -> String {
|
||||
|
||||
/// Merge global and per-project custom environment variables.
|
||||
/// Per-project variables override global variables with the same key.
|
||||
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||
pub(super) fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
|
||||
for ev in global {
|
||||
let key = ev.key.trim().to_string();
|
||||
@@ -494,7 +499,7 @@ fn sha256_hex(input: &str) -> String {
|
||||
|
||||
/// Resolve a per-project string value with a global fallback. Returns `None`
|
||||
/// when both are blank, otherwise the per-project value if set, else the global.
|
||||
fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
|
||||
pub(super) fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
|
||||
let project_val = per_project.map(str::trim).filter(|s| !s.is_empty());
|
||||
if project_val.is_some() {
|
||||
return project_val;
|
||||
@@ -659,7 +664,7 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
||||
/// to the global set and never turn a globally-enabled setting off. `None` at
|
||||
/// project level means "inherit", which is now a state the project can
|
||||
/// actually be in rather than the only state an off switch could produce.
|
||||
fn merge_claude_code_settings(
|
||||
pub(super) fn merge_claude_code_settings(
|
||||
global: Option<&ClaudeCodeSettings>,
|
||||
project: Option<&ClaudeCodeSettings>,
|
||||
) -> Option<ClaudeCodeSettings> {
|
||||
@@ -759,7 +764,7 @@ fn compute_claude_code_settings_fingerprint(
|
||||
///
|
||||
/// The other two are documented as "set to `1` to …" with no meaning attached
|
||||
/// to `0`, so `0` is unambiguously neutral and is stated outright.
|
||||
fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
|
||||
pub(super) fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
|
||||
let owned;
|
||||
let s = match settings {
|
||||
Some(s) => s,
|
||||
@@ -822,7 +827,7 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
|
||||
///
|
||||
/// Returns a `String` rather than an `Option<String>`: there is no longer any
|
||||
/// input for which this produces nothing to say.
|
||||
fn build_claude_code_settings_json(
|
||||
pub(super) fn build_claude_code_settings_json(
|
||||
settings: Option<&ClaudeCodeSettings>,
|
||||
sandbox_enabled: bool,
|
||||
) -> String {
|
||||
@@ -1080,7 +1085,7 @@ type VpnHostConfigParts = (
|
||||
/// `MISSION_CONTROL_ENABLED` twenty lines above shows the failure this avoids —
|
||||
/// it is pushed only when true, so a snapshot's baked `=1` survives the toggle
|
||||
/// going off.
|
||||
fn vpn_env_var(enabled: bool) -> String {
|
||||
pub(super) fn vpn_env_var(enabled: bool) -> String {
|
||||
format!("VPN_SUPPORT_ENABLED={}", u8::from(enabled))
|
||||
}
|
||||
|
||||
@@ -1266,212 +1271,24 @@ pub async fn create_container(
|
||||
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
||||
}
|
||||
|
||||
if let Some(ref token) = project.git_token {
|
||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||
}
|
||||
// Per-project git user overrides global defaults
|
||||
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
|
||||
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
|
||||
if let Some(name) = effective_git_name {
|
||||
env_vars.push(format!("GIT_USER_NAME={}", name));
|
||||
}
|
||||
if let Some(email) = effective_git_email {
|
||||
env_vars.push(format!("GIT_USER_EMAIL={}", email));
|
||||
}
|
||||
|
||||
// Bedrock configuration
|
||||
if project.backend == Backend::Bedrock {
|
||||
if let Some(ref bedrock) = project.bedrock_config {
|
||||
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
||||
|
||||
// AWS region: per-project overrides global
|
||||
let region = if !bedrock.aws_region.is_empty() {
|
||||
Some(bedrock.aws_region.clone())
|
||||
} else {
|
||||
global_aws.aws_region.clone()
|
||||
};
|
||||
if let Some(ref r) = region {
|
||||
env_vars.push(format!("AWS_REGION={}", r));
|
||||
}
|
||||
|
||||
match bedrock.auth_method {
|
||||
BedrockAuthMethod::StaticCredentials => {
|
||||
// Static/session credentials are NOT injected as env vars.
|
||||
// They are written to ~/.aws/credentials by
|
||||
// sync_bedrock_credentials() on every container
|
||||
// start, so rotated/updated keys are picked up without a
|
||||
// full container recreation (and never get baked into the
|
||||
// snapshot image). The empty values set by the
|
||||
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
|
||||
// the AWS SDK, which falls through to the credentials file.
|
||||
}
|
||||
BedrockAuthMethod::Profile => {
|
||||
// Per-project profile overrides global
|
||||
let profile = bedrock.aws_profile.as_ref()
|
||||
.or(global_aws.aws_profile.as_ref());
|
||||
if let Some(p) = profile {
|
||||
env_vars.push(format!("AWS_PROFILE={}", p));
|
||||
}
|
||||
env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string());
|
||||
}
|
||||
BedrockAuthMethod::BearerToken => {
|
||||
if let Some(ref token) = bedrock.aws_bearer_token {
|
||||
env_vars.push(format!("AWS_BEARER_TOKEN_BEDROCK={}", token));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model) = resolve_with_global(
|
||||
bedrock.model_id.as_deref(),
|
||||
global_aws.default_model_id.as_deref(),
|
||||
) {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
}
|
||||
|
||||
if bedrock.disable_prompt_caching {
|
||||
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref tier) = bedrock.service_tier {
|
||||
let trimmed = tier.trim();
|
||||
if !trimmed.is_empty() {
|
||||
env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom-endpoint backends ─────────────────────────────────────────────
|
||||
// Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code
|
||||
// at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model
|
||||
// id here; the model-alias vars are emitted once below, from
|
||||
// `alias_model` / `alias_haiku`, so the three backends cannot drift apart.
|
||||
let mut alias_model: Option<String> = None;
|
||||
let mut alias_haiku: Option<String> = None;
|
||||
|
||||
// Ollama configuration
|
||||
if project.backend == Backend::Ollama {
|
||||
if let Some(ref ollama) = project.ollama_config {
|
||||
if let Some(url) = resolve_with_global(
|
||||
Some(&ollama.base_url),
|
||||
global_ollama.base_url.as_deref(),
|
||||
) {
|
||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
|
||||
}
|
||||
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
|
||||
if let Some(model) = resolve_with_global(
|
||||
ollama.model_id.as_deref(),
|
||||
global_ollama.default_model_id.as_deref(),
|
||||
) {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
alias_model = Some(model.to_string());
|
||||
}
|
||||
alias_haiku = resolve_with_global(
|
||||
ollama.haiku_model_id.as_deref(),
|
||||
global_ollama.default_haiku_model_id.as_deref(),
|
||||
)
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
// llama.cpp (llama-server) configuration
|
||||
if project.backend == Backend::LlamaCpp {
|
||||
if let Some(ref cfg) = project.llamacpp_config {
|
||||
if let Some(url) = resolve_with_global(
|
||||
Some(&cfg.base_url),
|
||||
global_llamacpp.base_url.as_deref(),
|
||||
) {
|
||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
|
||||
}
|
||||
// llama-server only enforces an Authorization header when it was
|
||||
// started with `--api-key` (default: none), so the value here is
|
||||
// ignored in the common case. Claude Code still refuses to run
|
||||
// against a custom base URL with no credential at all, so a
|
||||
// placeholder is always sent — same trick as the Ollama branch
|
||||
// above, which sends the literal "ollama".
|
||||
env_vars.push("ANTHROPIC_AUTH_TOKEN=llama.cpp".to_string());
|
||||
if let Some(model) = resolve_with_global(
|
||||
cfg.model_id.as_deref(),
|
||||
global_llamacpp.default_model_id.as_deref(),
|
||||
) {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
alias_model = Some(model.to_string());
|
||||
}
|
||||
alias_haiku = resolve_with_global(
|
||||
cfg.haiku_model_id.as_deref(),
|
||||
global_llamacpp.default_haiku_model_id.as_deref(),
|
||||
)
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI Compatible configuration
|
||||
if project.backend == Backend::OpenAiCompatible {
|
||||
if let Some(ref config) = project.openai_compatible_config {
|
||||
if let Some(url) = resolve_with_global(
|
||||
Some(&config.base_url),
|
||||
global_openai_compatible.base_url.as_deref(),
|
||||
) {
|
||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
|
||||
}
|
||||
if let Some(ref key) = config.api_key {
|
||||
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
|
||||
}
|
||||
if let Some(model) = resolve_with_global(
|
||||
config.model_id.as_deref(),
|
||||
global_openai_compatible.default_model_id.as_deref(),
|
||||
) {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
alias_model = Some(model.to_string());
|
||||
}
|
||||
alias_haiku = resolve_with_global(
|
||||
config.haiku_model_id.as_deref(),
|
||||
global_openai_compatible.default_haiku_model_id.as_deref(),
|
||||
)
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
// Model aliases — the fix for background Claude Code calls against a local
|
||||
// server. Only for backends that talk to a custom endpoint: Anthropic and
|
||||
// Bedrock reach servers that really do host the Anthropic model ids, so
|
||||
// they keep Claude Code's own defaults. Anything not emitted here is
|
||||
// blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a
|
||||
// custom endpoint clears the aliases out of the snapshot image too.
|
||||
if project.backend.uses_custom_endpoint() {
|
||||
for (key, value) in
|
||||
compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref())
|
||||
{
|
||||
env_vars.push(format!("{}={}", key, value));
|
||||
}
|
||||
}
|
||||
|
||||
// Shared Claude Code OAuth token (Anthropic backend only, opt-out per
|
||||
// project). Injected *before* the neutralization pass below so that pass
|
||||
// sees it as already-set; when it is absent the pass actively blanks the
|
||||
// variable instead of leaving a stale one baked into the snapshot image.
|
||||
// project). Read here — it lives in the keychain — and handed to
|
||||
// `workspace_env`, which injects it before the neutralization pass.
|
||||
let shared_claude = shared_claude_auth(project);
|
||||
if let Some((ref token, _)) = shared_claude {
|
||||
env_vars.push(format!("{}={}", CLAUDE_OAUTH_TOKEN_ENV, token));
|
||||
if shared_claude.is_some() {
|
||||
log::info!(
|
||||
"Injecting the shared Claude authentication token into the container for project {}",
|
||||
project.id
|
||||
);
|
||||
}
|
||||
|
||||
// ── Corporate CA certificates ───────────────────────────────────────────
|
||||
// Resolved here (rather than down with the mounts) so the env vars land
|
||||
// *before* the neutralization pass below and are seen as already-set.
|
||||
// Corporate CA certificates, resolved here (not in `workspace_env`) because
|
||||
// the mount below needs the same resolution.
|
||||
//
|
||||
// A bad path is a hard error, not a warning: behind a TLS-terminating
|
||||
// proxy a container without the CA fails every HTTPS call — npm, pip, git,
|
||||
// and Claude Code's own API requests — each in its own confusing way. One
|
||||
// message naming the path is far kinder.
|
||||
//
|
||||
// The values are set here rather than exported by the entrypoint because a
|
||||
// terminal session is a `docker exec`, which sees the container's
|
||||
// configured env and nothing the entrypoint exported. Same lesson as
|
||||
// `$BROWSER` and the URL relay shim.
|
||||
let effective_ca_path =
|
||||
resolve_with_global(project.ca_cert_path.as_deref(), default_ca_cert_path);
|
||||
let resolved_ca = ca_certs::resolve(effective_ca_path)?;
|
||||
@@ -1483,110 +1300,40 @@ pub async fn create_container(
|
||||
project.id
|
||||
);
|
||||
}
|
||||
for (key, value) in ca_certs::ca_env_vars(resolved_ca.as_ref()) {
|
||||
env_vars.push(format!("{}={}", key, value));
|
||||
}
|
||||
|
||||
// ── Neutralize stale backend auth env vars ──────────────────────────────
|
||||
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
||||
// is recreated *from a snapshot image* committed off the previous container,
|
||||
// and `docker commit` copies that container's full ENV into the image. So
|
||||
// any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1,
|
||||
// AWS_PROFILE, a model alias) survives in the image ENV and stays active
|
||||
// unless we explicitly override it at create time.
|
||||
//
|
||||
// This pass is about *staleness*, not secrecy. It fixes the container it is
|
||||
// building and does nothing to the image, so it is not — and never was —
|
||||
// a defence against a credential baked into a snapshot. That is
|
||||
// `commit_container_snapshot`'s job, via SECRET_ENV_KEYS.
|
||||
// Create-time env takes precedence over image ENV, so we set every managed
|
||||
// auth key the *current* backend did NOT set to an empty value, clearing the
|
||||
// stale baked-in one.
|
||||
const MANAGED_AUTH_KEYS: &[&str] = &[
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"AWS_REGION",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_PROFILE",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_MODEL",
|
||||
"DISABLE_PROMPT_CACHING",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER",
|
||||
// Switching from a custom-endpoint backend to Anthropic or Bedrock must
|
||||
// *clear* the aliases, not merely stop setting them: a stale
|
||||
// ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would
|
||||
// keep pointing background calls at a model id the new backend has
|
||||
// never heard of.
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL,
|
||||
// Revoking the shared token, opting a project out, or switching away
|
||||
// from the Anthropic backend must *clear* this, not merely stop setting
|
||||
// it — otherwise the value committed into the snapshot image keeps
|
||||
// authenticating the container with a credential the user removed.
|
||||
CLAUDE_OAUTH_TOKEN_ENV,
|
||||
];
|
||||
// Same reasoning for the CA vars — `ca_env_vars` already emits them empty
|
||||
// when no CA is configured, so this list is belt-and-braces for a snapshot
|
||||
// committed by a build that predates the feature.
|
||||
let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(ca_certs::CA_ENV_KEYS.iter().copied())
|
||||
.collect();
|
||||
let already_set: std::collections::HashSet<String> = env_vars
|
||||
.iter()
|
||||
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
|
||||
.collect();
|
||||
for key in &managed_keys {
|
||||
if !already_set.contains(*key) {
|
||||
env_vars.push(format!("{}=", key));
|
||||
}
|
||||
}
|
||||
// Everything project-shaped (backend auth, CA vars, custom env, instructions,
|
||||
// Claude Code settings, ...) comes from the one host-neutral builder, so the
|
||||
// AWS host can put the same pairs in its secret.
|
||||
env_vars.extend(
|
||||
workspace_env(
|
||||
project,
|
||||
&WorkspaceEnvInputs {
|
||||
global_aws,
|
||||
global_ollama,
|
||||
global_llamacpp,
|
||||
global_openai_compatible,
|
||||
global_claude_instructions,
|
||||
global_custom_env_vars,
|
||||
timezone,
|
||||
global_claude_code_settings,
|
||||
default_git_user_name,
|
||||
default_git_user_email,
|
||||
resolved_ca: resolved_ca.as_ref(),
|
||||
shared_claude_token: shared_claude.as_ref().map(|(token, _)| token.as_str()),
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{}={}", key, value)),
|
||||
);
|
||||
|
||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||
for env_var in &merged_env {
|
||||
let key = env_var.key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if is_reserved_env_key(key) {
|
||||
log::warn!("Skipping reserved env var: {}", key);
|
||||
continue;
|
||||
}
|
||||
env_vars.push(format!("{}={}", key, env_var.value));
|
||||
}
|
||||
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
||||
|
||||
// Container timezone
|
||||
if let Some(tz) = timezone {
|
||||
if !tz.is_empty() {
|
||||
env_vars.push(format!("TZ={}", tz));
|
||||
}
|
||||
}
|
||||
|
||||
// Mission Control env var
|
||||
if project.mission_control_enabled {
|
||||
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
|
||||
}
|
||||
|
||||
env_vars.push(vpn_env_var(project.vpn_support_enabled));
|
||||
|
||||
// Permission mode — read by triple-c-task-runner for scheduled (headless)
|
||||
// Claude Code runs. Interactive terminals get the flags directly instead.
|
||||
env_vars.push(format!(
|
||||
"TRIPLE_C_PERMISSION_MODE={}",
|
||||
project.effective_permission_mode().as_env_value()
|
||||
// The labels below fingerprint the inputs that went into the env; derive
|
||||
// them with the same helpers `workspace_env` used, so both always agree.
|
||||
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
|
||||
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
|
||||
let custom_env_fingerprint = compute_env_fingerprint(&merge_custom_env_vars(
|
||||
global_custom_env_vars,
|
||||
&project.custom_env_vars,
|
||||
));
|
||||
|
||||
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||
let combined_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
@@ -1594,34 +1341,10 @@ pub async fn create_container(
|
||||
project.mission_control_enabled,
|
||||
project.sandbox_mode_enabled,
|
||||
);
|
||||
|
||||
if let Some(ref instructions) = combined_instructions {
|
||||
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
||||
}
|
||||
|
||||
// Claude Code settings (global + per-project merged)
|
||||
let merged_cc_settings = merge_claude_code_settings(
|
||||
global_claude_code_settings,
|
||||
project.claude_code_settings.as_ref(),
|
||||
);
|
||||
// Env-var-based settings, read directly by Claude Code. Extracted and unit
|
||||
// tested for the `vpn_host_config` reason: a container is created once, by
|
||||
// a very long function, and a variable emitted with the wrong value here is
|
||||
// invisible until someone wonders why a switch does nothing.
|
||||
env_vars.extend(claude_code_env_vars(merged_cc_settings.as_ref()));
|
||||
|
||||
// settings.json-based settings (applied by the entrypoint). Always emitted,
|
||||
// even with no `ClaudeCodeSettings` struct present: the payload asserts the
|
||||
// *whole* managed key set, so "no settings" still has to be stated over a
|
||||
// settings.json left behind on the config volume by a previous config.
|
||||
// Split so the payload is safe under an older entrypoint that has never
|
||||
// heard of the null-means-delete convention — see
|
||||
// `split_claude_code_settings_payload`.
|
||||
let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload(
|
||||
&build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled),
|
||||
);
|
||||
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", cc_settings_set));
|
||||
env_vars.push(format!("CLAUDE_CODE_SETTINGS_CLEAR={}", cc_settings_clear));
|
||||
|
||||
let mut mounts: Vec<Mount> = Vec::new();
|
||||
|
||||
@@ -4377,6 +4100,22 @@ mod tests {
|
||||
assert_eq!(vpn_env_var(false), "VPN_SUPPORT_ENABLED=0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_aws_workspace_namespace_is_reserved_from_custom_env() {
|
||||
for key in ["TRIPLEC_HOST", "TRIPLEC_FILES", "TRIPLEC_AGENT_TOKEN", "triplec_repos", "TRIPLEC_ANYTHING"] {
|
||||
assert!(is_reserved_env_key(key), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
compute_env_fingerprint(&[EnvVar {
|
||||
key: "TRIPLEC_HOST".to_string(),
|
||||
value: "aws".to_string(),
|
||||
}]),
|
||||
"",
|
||||
"a reserved key never reaches the container"
|
||||
);
|
||||
assert!(!is_reserved_env_key("TRIPLECX"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vpn_skill_flag_is_reserved_from_custom_env() {
|
||||
// entrypoint.sh installs and removes the pia-vpn skill from this
|
||||
|
||||
@@ -17,6 +17,8 @@ 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");
|
||||
const WIP_PUSH: &str = include_str!("../../../../container/triple-c-wip-push");
|
||||
const IDLE_WATCH: &str = include_str!("../../../../container/triple-c-idle-watch");
|
||||
|
||||
static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control");
|
||||
static SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/skills");
|
||||
@@ -245,6 +247,8 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
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_file_to_archive(&mut archive, "triple-c-wip-push", WIP_PUSH.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-idle-watch", IDLE_WATCH.as_bytes(), 0o755)?;
|
||||
|
||||
append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?;
|
||||
append_embedded_dir(&mut archive, &SKILLS_DIR, "skills")?;
|
||||
|
||||
@@ -2,12 +2,15 @@ pub mod ca_certs;
|
||||
pub mod client;
|
||||
pub mod events;
|
||||
pub mod container;
|
||||
pub mod workspace_env;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
pub mod gateway;
|
||||
pub mod legacy_cleanup;
|
||||
pub mod migration;
|
||||
pub mod stt;
|
||||
#[cfg(all(test, unix))]
|
||||
mod aws_mode_tests;
|
||||
|
||||
/// The container user `claude`'s home directory, as the image creates it.
|
||||
pub(crate) const CONTAINER_HOME: &str = "/home/claude";
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
//! The container environment a project gets, built once for every host.
|
||||
//!
|
||||
//! Docker passes these pairs as the container's `Env`; the AWS host stores the
|
||||
//! very same pairs in a per-project Secrets Manager secret. Both therefore call
|
||||
//! [`workspace_env`], so a backend, a CA bundle or a custom variable cannot
|
||||
//! reach one host and not the other.
|
||||
//!
|
||||
//! What is *not* here: `COLORTERM`, `HOST_UID` and `HOST_GID` (host-specific,
|
||||
//! assembled by `create_container`), and anything that needs the host — the
|
||||
//! corporate CA is resolved from disk and the shared Claude token read from
|
||||
//! the keychain by the caller, then handed in through [`WorkspaceEnvInputs`].
|
||||
//! Order is part of the contract: it is the order `docker inspect` shows.
|
||||
|
||||
use super::ca_certs::{self, ResolvedCa};
|
||||
use super::container::{
|
||||
build_claude_code_settings_json, build_claude_instructions, claude_code_env_vars,
|
||||
compute_env_fingerprint, compute_model_aliases, is_reserved_env_key, merge_claude_code_settings,
|
||||
merge_custom_env_vars, resolve_with_global, split_claude_code_settings_payload, vpn_env_var,
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL, CLAUDE_OAUTH_TOKEN_ENV,
|
||||
};
|
||||
use crate::models::{
|
||||
Backend, BedrockAuthMethod, ClaudeCodeSettings, EnvVar, GlobalAwsSettings,
|
||||
GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, Project,
|
||||
};
|
||||
|
||||
/// Everything besides the project that shapes its environment.
|
||||
pub(crate) struct WorkspaceEnvInputs<'a> {
|
||||
pub global_aws: &'a GlobalAwsSettings,
|
||||
pub global_ollama: &'a GlobalOllamaSettings,
|
||||
pub global_llamacpp: &'a GlobalLlamaCppSettings,
|
||||
pub global_openai_compatible: &'a GlobalOpenAiCompatibleSettings,
|
||||
pub global_claude_instructions: Option<&'a str>,
|
||||
pub global_custom_env_vars: &'a [EnvVar],
|
||||
pub timezone: Option<&'a str>,
|
||||
pub global_claude_code_settings: Option<&'a ClaudeCodeSettings>,
|
||||
pub default_git_user_name: Option<&'a str>,
|
||||
pub default_git_user_email: Option<&'a str>,
|
||||
/// The corporate CA, already resolved from disk (`ca_certs::resolve`).
|
||||
pub resolved_ca: Option<&'a ResolvedCa>,
|
||||
/// The shared Claude OAuth token, already read from the keychain and
|
||||
/// already filtered by `shared_claude_auth` (backend, opt-out, non-blank).
|
||||
pub shared_claude_token: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `"KEY=VALUE"` to a pair, splitting at the first `=` (values may contain `=`).
|
||||
fn split_pair(entry: &str) -> (String, String) {
|
||||
match entry.split_once('=') {
|
||||
Some((k, v)) => (k.to_string(), v.to_string()),
|
||||
None => (entry.to_string(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `KEY=VALUE` pairs a project's container gets, in order, excluding the
|
||||
/// host-specific ones. See the module docs.
|
||||
pub(crate) fn workspace_env(
|
||||
project: &Project,
|
||||
inputs: &WorkspaceEnvInputs,
|
||||
) -> Vec<(String, String)> {
|
||||
let WorkspaceEnvInputs {
|
||||
global_aws,
|
||||
global_ollama,
|
||||
global_llamacpp,
|
||||
global_openai_compatible,
|
||||
global_claude_instructions,
|
||||
global_custom_env_vars,
|
||||
timezone,
|
||||
global_claude_code_settings,
|
||||
default_git_user_name,
|
||||
default_git_user_email,
|
||||
resolved_ca,
|
||||
shared_claude_token,
|
||||
} = *inputs;
|
||||
let mut env: Vec<(String, String)> = Vec::new();
|
||||
|
||||
if let Some(ref token) = project.git_token {
|
||||
env.push(("GIT_TOKEN".to_string(), token.to_string()));
|
||||
}
|
||||
// Per-project git user overrides global defaults
|
||||
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
|
||||
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
|
||||
if let Some(name) = effective_git_name {
|
||||
env.push(("GIT_USER_NAME".to_string(), name.to_string()));
|
||||
}
|
||||
if let Some(email) = effective_git_email {
|
||||
env.push(("GIT_USER_EMAIL".to_string(), email.to_string()));
|
||||
}
|
||||
|
||||
// Bedrock configuration
|
||||
if project.backend == Backend::Bedrock {
|
||||
if let Some(ref bedrock) = project.bedrock_config {
|
||||
env.push(("CLAUDE_CODE_USE_BEDROCK".to_string(), "1".to_string()));
|
||||
|
||||
// AWS region: per-project overrides global
|
||||
let region = if !bedrock.aws_region.is_empty() {
|
||||
Some(bedrock.aws_region.clone())
|
||||
} else {
|
||||
global_aws.aws_region.clone()
|
||||
};
|
||||
if let Some(ref r) = region {
|
||||
env.push(("AWS_REGION".to_string(), r.to_string()));
|
||||
}
|
||||
|
||||
match bedrock.auth_method {
|
||||
BedrockAuthMethod::StaticCredentials => {
|
||||
// Static/session credentials are NOT injected as env vars.
|
||||
// They are written to ~/.aws/credentials by
|
||||
// sync_bedrock_credentials() on every container
|
||||
// start, so rotated/updated keys are picked up without a
|
||||
// full container recreation (and never get baked into the
|
||||
// snapshot image). The empty values set by the
|
||||
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
|
||||
// the AWS SDK, which falls through to the credentials file.
|
||||
}
|
||||
BedrockAuthMethod::Profile => {
|
||||
// Per-project profile overrides global
|
||||
let profile = bedrock.aws_profile.as_ref()
|
||||
.or(global_aws.aws_profile.as_ref());
|
||||
if let Some(p) = profile {
|
||||
env.push(("AWS_PROFILE".to_string(), p.to_string()));
|
||||
}
|
||||
env.push(("AWS_SSO_AUTH_REFRESH_CMD".to_string(), "triple-c-sso-refresh".to_string()));
|
||||
}
|
||||
BedrockAuthMethod::BearerToken => {
|
||||
if let Some(ref token) = bedrock.aws_bearer_token {
|
||||
env.push(("AWS_BEARER_TOKEN_BEDROCK".to_string(), token.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model) = resolve_with_global(
|
||||
bedrock.model_id.as_deref(),
|
||||
global_aws.default_model_id.as_deref(),
|
||||
) {
|
||||
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
|
||||
}
|
||||
|
||||
if bedrock.disable_prompt_caching {
|
||||
env.push(("DISABLE_PROMPT_CACHING".to_string(), "1".to_string()));
|
||||
}
|
||||
|
||||
if let Some(ref tier) = bedrock.service_tier {
|
||||
let trimmed = tier.trim();
|
||||
if !trimmed.is_empty() {
|
||||
env.push(("ANTHROPIC_BEDROCK_SERVICE_TIER".to_string(), trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom-endpoint backends ─────────────────────────────────────────────
|
||||
// Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code
|
||||
// at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model
|
||||
// id here; the model-alias vars are emitted once below, from
|
||||
// `alias_model` / `alias_haiku`, so the three backends cannot drift apart.
|
||||
let mut alias_model: Option<String> = None;
|
||||
let mut alias_haiku: Option<String> = None;
|
||||
|
||||
// Ollama configuration
|
||||
if project.backend == Backend::Ollama {
|
||||
if let Some(ref ollama) = project.ollama_config {
|
||||
if let Some(url) = resolve_with_global(
|
||||
Some(&ollama.base_url),
|
||||
global_ollama.base_url.as_deref(),
|
||||
) {
|
||||
env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string()));
|
||||
}
|
||||
env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), "ollama".to_string()));
|
||||
if let Some(model) = resolve_with_global(
|
||||
ollama.model_id.as_deref(),
|
||||
global_ollama.default_model_id.as_deref(),
|
||||
) {
|
||||
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
|
||||
alias_model = Some(model.to_string());
|
||||
}
|
||||
alias_haiku = resolve_with_global(
|
||||
ollama.haiku_model_id.as_deref(),
|
||||
global_ollama.default_haiku_model_id.as_deref(),
|
||||
)
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
// llama.cpp (llama-server) configuration
|
||||
if project.backend == Backend::LlamaCpp {
|
||||
if let Some(ref cfg) = project.llamacpp_config {
|
||||
if let Some(url) = resolve_with_global(
|
||||
Some(&cfg.base_url),
|
||||
global_llamacpp.base_url.as_deref(),
|
||||
) {
|
||||
env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string()));
|
||||
}
|
||||
// llama-server only enforces an Authorization header when it was
|
||||
// started with `--api-key` (default: none), so the value here is
|
||||
// ignored in the common case. Claude Code still refuses to run
|
||||
// against a custom base URL with no credential at all, so a
|
||||
// placeholder is always sent — same trick as the Ollama branch
|
||||
// above, which sends the literal "ollama".
|
||||
env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), "llama.cpp".to_string()));
|
||||
if let Some(model) = resolve_with_global(
|
||||
cfg.model_id.as_deref(),
|
||||
global_llamacpp.default_model_id.as_deref(),
|
||||
) {
|
||||
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
|
||||
alias_model = Some(model.to_string());
|
||||
}
|
||||
alias_haiku = resolve_with_global(
|
||||
cfg.haiku_model_id.as_deref(),
|
||||
global_llamacpp.default_haiku_model_id.as_deref(),
|
||||
)
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI Compatible configuration
|
||||
if project.backend == Backend::OpenAiCompatible {
|
||||
if let Some(ref config) = project.openai_compatible_config {
|
||||
if let Some(url) = resolve_with_global(
|
||||
Some(&config.base_url),
|
||||
global_openai_compatible.base_url.as_deref(),
|
||||
) {
|
||||
env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string()));
|
||||
}
|
||||
if let Some(ref key) = config.api_key {
|
||||
env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), key.to_string()));
|
||||
}
|
||||
if let Some(model) = resolve_with_global(
|
||||
config.model_id.as_deref(),
|
||||
global_openai_compatible.default_model_id.as_deref(),
|
||||
) {
|
||||
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
|
||||
alias_model = Some(model.to_string());
|
||||
}
|
||||
alias_haiku = resolve_with_global(
|
||||
config.haiku_model_id.as_deref(),
|
||||
global_openai_compatible.default_haiku_model_id.as_deref(),
|
||||
)
|
||||
.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
// Model aliases — the fix for background Claude Code calls against a local
|
||||
// server. Only for backends that talk to a custom endpoint: Anthropic and
|
||||
// Bedrock reach servers that really do host the Anthropic model ids, so
|
||||
// they keep Claude Code's own defaults. Anything not emitted here is
|
||||
// blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a
|
||||
// custom endpoint clears the aliases out of the snapshot image too.
|
||||
if project.backend.uses_custom_endpoint() {
|
||||
for (key, value) in
|
||||
compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref())
|
||||
{
|
||||
env.push((key.to_string(), value));
|
||||
}
|
||||
}
|
||||
|
||||
// Shared Claude Code OAuth token (Anthropic backend only, opt-out per
|
||||
// project). Injected *before* the neutralization pass below so that pass
|
||||
// sees it as already-set; when it is absent the pass actively blanks the
|
||||
// variable instead of leaving a stale one baked into the snapshot image.
|
||||
if let Some(token) = shared_claude_token {
|
||||
env.push((CLAUDE_OAUTH_TOKEN_ENV.to_string(), token.to_string()));
|
||||
}
|
||||
|
||||
// ── Corporate CA certificates ───────────────────────────────────────────
|
||||
// Resolved here (rather than down with the mounts) so the env vars land
|
||||
// *before* the neutralization pass below and are seen as already-set.
|
||||
//
|
||||
// A bad path is a hard error, not a warning: behind a TLS-terminating
|
||||
// proxy a container without the CA fails every HTTPS call — npm, pip, git,
|
||||
// and Claude Code's own API requests — each in its own confusing way. One
|
||||
// message naming the path is far kinder.
|
||||
//
|
||||
// The values are set here rather than exported by the entrypoint because a
|
||||
// terminal session is a `docker exec`, which sees the container's
|
||||
// configured env and nothing the entrypoint exported. Same lesson as
|
||||
// `$BROWSER` and the URL relay shim.
|
||||
for (key, value) in ca_certs::ca_env_vars(resolved_ca) {
|
||||
env.push((key.to_string(), value));
|
||||
}
|
||||
|
||||
// ── Neutralize stale backend auth env vars ──────────────────────────────
|
||||
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
||||
// is recreated *from a snapshot image* committed off the previous container,
|
||||
// and `docker commit` copies that container's full ENV into the image. So
|
||||
// any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1,
|
||||
// AWS_PROFILE, a model alias) survives in the image ENV and stays active
|
||||
// unless we explicitly override it at create time.
|
||||
//
|
||||
// This pass is about *staleness*, not secrecy. It fixes the container it is
|
||||
// building and does nothing to the image, so it is not — and never was —
|
||||
// a defence against a credential baked into a snapshot. That is
|
||||
// `commit_container_snapshot`'s job, via SECRET_ENV_KEYS.
|
||||
// Create-time env takes precedence over image ENV, so we set every managed
|
||||
// auth key the *current* backend did NOT set to an empty value, clearing the
|
||||
// stale baked-in one.
|
||||
const MANAGED_AUTH_KEYS: &[&str] = &[
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"AWS_REGION",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_PROFILE",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_MODEL",
|
||||
"DISABLE_PROMPT_CACHING",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER",
|
||||
// Switching from a custom-endpoint backend to Anthropic or Bedrock must
|
||||
// *clear* the aliases, not merely stop setting them: a stale
|
||||
// ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would
|
||||
// keep pointing background calls at a model id the new backend has
|
||||
// never heard of.
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL,
|
||||
// Revoking the shared token, opting a project out, or switching away
|
||||
// from the Anthropic backend must *clear* this, not merely stop setting
|
||||
// it — otherwise the value committed into the snapshot image keeps
|
||||
// authenticating the container with a credential the user removed.
|
||||
CLAUDE_OAUTH_TOKEN_ENV,
|
||||
];
|
||||
// Same reasoning for the CA vars — `ca_env` already emits them empty
|
||||
// when no CA is configured, so this list is belt-and-braces for a snapshot
|
||||
// committed by a build that predates the feature.
|
||||
let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(ca_certs::CA_ENV_KEYS.iter().copied())
|
||||
.collect();
|
||||
let already_set: std::collections::HashSet<String> =
|
||||
env.iter().map(|(k, _)| k.clone()).collect();
|
||||
for key in &managed_keys {
|
||||
if !already_set.contains(*key) {
|
||||
env.push((key.to_string(), String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||
for env_var in &merged_env {
|
||||
let key = env_var.key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if is_reserved_env_key(key) {
|
||||
log::warn!("Skipping reserved env var: {}", key);
|
||||
continue;
|
||||
}
|
||||
env.push((key.to_string(), env_var.value.clone()));
|
||||
}
|
||||
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||
env.push(("TRIPLE_C_CUSTOM_ENV".to_string(), custom_env_fingerprint.to_string()));
|
||||
|
||||
// Container timezone
|
||||
if let Some(tz) = timezone {
|
||||
if !tz.is_empty() {
|
||||
env.push(("TZ".to_string(), tz.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Mission Control env var
|
||||
if project.mission_control_enabled {
|
||||
env.push(("MISSION_CONTROL_ENABLED".to_string(), "1".to_string()));
|
||||
}
|
||||
|
||||
env.push(split_pair(&vpn_env_var(project.vpn_support_enabled)));
|
||||
|
||||
// Permission mode — read by triple-c-task-runner for scheduled (headless)
|
||||
// Claude Code runs. Interactive terminals get the flags directly instead.
|
||||
env.push((
|
||||
"TRIPLE_C_PERMISSION_MODE".to_string(),
|
||||
project.effective_permission_mode().as_env_value().to_string(),
|
||||
));
|
||||
|
||||
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||
let combined_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
project.sandbox_mode_enabled,
|
||||
);
|
||||
|
||||
if let Some(ref instructions) = combined_instructions {
|
||||
env.push(("CLAUDE_INSTRUCTIONS".to_string(), instructions.to_string()));
|
||||
}
|
||||
|
||||
// Claude Code settings (global + per-project merged)
|
||||
let merged_cc_settings = merge_claude_code_settings(
|
||||
global_claude_code_settings,
|
||||
project.claude_code_settings.as_ref(),
|
||||
);
|
||||
// Env-var-based settings, read directly by Claude Code. Extracted and unit
|
||||
// tested for the `vpn_host_config` reason: a container is created once, by
|
||||
// a very long function, and a variable emitted with the wrong value here is
|
||||
// invisible until someone wonders why a switch does nothing.
|
||||
env.extend(
|
||||
claude_code_env_vars(merged_cc_settings.as_ref())
|
||||
.iter()
|
||||
.map(|entry| split_pair(entry)),
|
||||
);
|
||||
|
||||
// settings.json-based settings (applied by the entrypoint). Always emitted,
|
||||
// even with no `ClaudeCodeSettings` struct present: the payload asserts the
|
||||
// *whole* managed key set, so "no settings" still has to be stated over a
|
||||
// settings.json left behind on the config volume by a previous config.
|
||||
// Split so the payload is safe under an older entrypoint that has never
|
||||
// heard of the null-means-delete convention — see
|
||||
// `split_claude_code_settings_payload`.
|
||||
let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload(
|
||||
&build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled),
|
||||
);
|
||||
env.push(("CLAUDE_CODE_SETTINGS_JSON".to_string(), cc_settings_set.to_string()));
|
||||
env.push(("CLAUDE_CODE_SETTINGS_CLEAR".to_string(), cc_settings_clear.to_string()));
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
|
||||
/// [`workspace_env`] with its inputs resolved from `settings` exactly as
|
||||
/// `create_container` resolves them (the CA from disk, the shared Claude
|
||||
/// token from the keychain), for a host that does not create a Docker
|
||||
/// container. Also returns the resolved CA, whose files the caller ships.
|
||||
pub(crate) fn workspace_env_from_settings(
|
||||
project: &Project,
|
||||
settings: &crate::models::AppSettings,
|
||||
) -> Result<crate::host::aws_host::AwsEnv, String> {
|
||||
let shared_claude = super::container::shared_claude_auth(project);
|
||||
let effective_ca_path =
|
||||
resolve_with_global(project.ca_cert_path.as_deref(), settings.ca_cert_path.as_deref());
|
||||
let resolved_ca = ca_certs::resolve(effective_ca_path)?;
|
||||
let pairs = workspace_env(
|
||||
project,
|
||||
&WorkspaceEnvInputs {
|
||||
global_aws: &settings.global_aws,
|
||||
global_ollama: &settings.global_ollama,
|
||||
global_llamacpp: &settings.global_llamacpp,
|
||||
global_openai_compatible: &settings.global_openai_compatible,
|
||||
global_claude_instructions: settings.global_claude_instructions.as_deref(),
|
||||
global_custom_env_vars: &settings.global_custom_env_vars,
|
||||
timezone: settings.timezone.as_deref(),
|
||||
global_claude_code_settings: settings.global_claude_code_settings.as_ref(),
|
||||
default_git_user_name: settings.default_git_user_name.as_deref(),
|
||||
default_git_user_email: settings.default_git_user_email.as_deref(),
|
||||
resolved_ca: resolved_ca.as_ref(),
|
||||
shared_claude_token: shared_claude.as_ref().map(|(token, _)| token.as_str()),
|
||||
},
|
||||
);
|
||||
Ok((pairs, resolved_ca))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::ca_certs::ResolvedCa;
|
||||
use crate::models::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Every input the env block reads, owned, so a table row is one value.
|
||||
pub(crate) struct Case {
|
||||
pub name: &'static str,
|
||||
pub project: Project,
|
||||
pub global_aws: GlobalAwsSettings,
|
||||
pub global_ollama: GlobalOllamaSettings,
|
||||
pub global_llamacpp: GlobalLlamaCppSettings,
|
||||
pub global_openai: GlobalOpenAiCompatibleSettings,
|
||||
pub global_instructions: Option<&'static str>,
|
||||
pub global_env: Vec<EnvVar>,
|
||||
pub timezone: Option<&'static str>,
|
||||
pub global_cc: Option<ClaudeCodeSettings>,
|
||||
pub default_git_name: Option<&'static str>,
|
||||
pub default_git_email: Option<&'static str>,
|
||||
pub ca: Option<ResolvedCa>,
|
||||
pub shared_token: Option<(String, String)>,
|
||||
}
|
||||
|
||||
fn case(name: &'static str, project: Project) -> Case {
|
||||
Case {
|
||||
name,
|
||||
project,
|
||||
global_aws: GlobalAwsSettings::default(),
|
||||
global_ollama: GlobalOllamaSettings::default(),
|
||||
global_llamacpp: GlobalLlamaCppSettings::default(),
|
||||
global_openai: GlobalOpenAiCompatibleSettings::default(),
|
||||
global_instructions: None,
|
||||
global_env: Vec::new(),
|
||||
timezone: None,
|
||||
global_cc: None,
|
||||
default_git_name: None,
|
||||
default_git_email: None,
|
||||
ca: None,
|
||||
shared_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn proj(backend: Backend) -> Project {
|
||||
let mut p = Project::new("demo".to_string(), Vec::new());
|
||||
p.backend = backend;
|
||||
p
|
||||
}
|
||||
|
||||
fn bedrock(method: BedrockAuthMethod) -> BedrockConfig {
|
||||
BedrockConfig {
|
||||
auth_method: method,
|
||||
aws_region: "us-east-1".into(),
|
||||
aws_access_key_id: Some("AKIAFAKE".into()),
|
||||
aws_secret_access_key: Some("secretfake".into()),
|
||||
aws_session_token: None,
|
||||
aws_profile: Some("proj-profile".into()),
|
||||
aws_bearer_token: Some("bearerfake".into()),
|
||||
model_id: Some("anthropic.claude-x".into()),
|
||||
disable_prompt_caching: true,
|
||||
service_tier: Some(" priority ".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cases() -> Vec<Case> {
|
||||
let mut v = Vec::new();
|
||||
|
||||
let mut c = case("anthropic_shared_token", proj(Backend::Anthropic));
|
||||
c.shared_token = Some(("sk-shared-token".into(), "v7".into()));
|
||||
v.push(c);
|
||||
|
||||
v.push(case("anthropic_plain", proj(Backend::Anthropic)));
|
||||
|
||||
let mut p = proj(Backend::Bedrock);
|
||||
p.bedrock_config = Some(bedrock(BedrockAuthMethod::StaticCredentials));
|
||||
let mut c = case("bedrock_static_keys", p);
|
||||
c.global_aws.default_model_id = Some("global-model".into());
|
||||
v.push(c);
|
||||
|
||||
let mut p = proj(Backend::Bedrock);
|
||||
let mut b = bedrock(BedrockAuthMethod::Profile);
|
||||
b.aws_region = String::new();
|
||||
b.aws_profile = None;
|
||||
b.model_id = None;
|
||||
b.disable_prompt_caching = false;
|
||||
b.service_tier = Some(" ".into());
|
||||
p.bedrock_config = Some(b);
|
||||
let mut c = case("bedrock_profile_global_fallbacks", p);
|
||||
c.global_aws.aws_region = Some("eu-west-1".into());
|
||||
c.global_aws.aws_profile = Some("global-profile".into());
|
||||
c.global_aws.default_model_id = Some("global-model".into());
|
||||
v.push(c);
|
||||
|
||||
let mut p = proj(Backend::Bedrock);
|
||||
p.bedrock_config = Some(bedrock(BedrockAuthMethod::BearerToken));
|
||||
v.push(case("bedrock_bearer", p));
|
||||
|
||||
let mut p = proj(Backend::Ollama);
|
||||
p.ollama_config = Some(OllamaConfig {
|
||||
base_url: "http://ollama.local:11434".into(),
|
||||
model_id: Some("qwen3".into()),
|
||||
haiku_model_id: None,
|
||||
});
|
||||
let mut c = case("ollama", p);
|
||||
c.global_ollama.default_haiku_model_id = Some("tiny".into());
|
||||
v.push(c);
|
||||
|
||||
let mut p = proj(Backend::LlamaCpp);
|
||||
p.llamacpp_config = Some(LlamaCppConfig {
|
||||
base_url: String::new(),
|
||||
model_id: None,
|
||||
haiku_model_id: Some("llama-small".into()),
|
||||
});
|
||||
let mut c = case("llamacpp_global_url", p);
|
||||
c.global_llamacpp.base_url = Some("http://llama:8080".into());
|
||||
c.global_llamacpp.default_model_id = Some("llama-big".into());
|
||||
v.push(c);
|
||||
|
||||
let mut p = proj(Backend::OpenAiCompatible);
|
||||
p.openai_compatible_config = Some(OpenAiCompatibleConfig {
|
||||
base_url: "https://gw.example/v1".into(),
|
||||
api_key: Some("sk-gateway".into()),
|
||||
model_id: Some("gpt-x".into()),
|
||||
haiku_model_id: Some("gpt-mini".into()),
|
||||
});
|
||||
v.push(case("openai_compatible", p));
|
||||
|
||||
let mut p = proj(Backend::Anthropic);
|
||||
p.custom_env_vars = vec![
|
||||
EnvVar { key: "FOO".into(), value: "project".into() },
|
||||
EnvVar { key: " SPACED ".into(), value: "x y".into() },
|
||||
EnvVar { key: "".into(), value: "dropped".into() },
|
||||
EnvVar { key: "HOME".into(), value: "reserved".into() },
|
||||
EnvVar { key: "CLAUDE_CODE_OAUTH_TOKEN".into(), value: "reserved".into() },
|
||||
];
|
||||
let mut c = case("custom_env", p);
|
||||
c.global_env = vec![
|
||||
EnvVar { key: "FOO".into(), value: "global".into() },
|
||||
EnvVar { key: "BAR".into(), value: "b".into() },
|
||||
];
|
||||
v.push(c);
|
||||
|
||||
let mut p = proj(Backend::Anthropic);
|
||||
p.claude_instructions = Some("project rules".into());
|
||||
p.mission_control_enabled = true;
|
||||
p.sandbox_mode_enabled = true;
|
||||
p.port_mappings = vec![PortMapping {
|
||||
host_port: 8080,
|
||||
container_port: 3000,
|
||||
protocol: "tcp".into(),
|
||||
}];
|
||||
let mut c = case("instructions_and_mission_control", p);
|
||||
c.global_instructions = Some("global rules\nline two");
|
||||
v.push(c);
|
||||
|
||||
let mut p = proj(Backend::Anthropic);
|
||||
p.claude_code_settings = Some(ClaudeCodeSettings {
|
||||
effort: Some("high".into()),
|
||||
focus_mode: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
let mut c = case("claude_code_settings", p);
|
||||
c.global_cc = Some(ClaudeCodeSettings {
|
||||
tui_mode: Some("fullscreen".into()),
|
||||
env_scrub: Some(true),
|
||||
prompt_caching_1h: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
v.push(c);
|
||||
|
||||
let mut p = proj(Backend::Anthropic);
|
||||
p.vpn_support_enabled = true;
|
||||
p.git_token = Some("ghp_faketoken".into());
|
||||
p.git_user_name = Some("Project User".into());
|
||||
let mut c = case("vpn_git_timezone", p);
|
||||
c.timezone = Some("Europe/Berlin");
|
||||
c.default_git_name = Some("Default Name");
|
||||
c.default_git_email = Some("default@example.com");
|
||||
v.push(c);
|
||||
|
||||
let mut c = case("corporate_ca", proj(Backend::Anthropic));
|
||||
c.ca = Some(ResolvedCa {
|
||||
host_path: "/certs".into(),
|
||||
is_dir: true,
|
||||
mount_target: "/tmp/.host-ca".into(),
|
||||
cert_files: vec![PathBuf::from("/certs/a.crt")],
|
||||
});
|
||||
v.push(c);
|
||||
|
||||
v
|
||||
}
|
||||
|
||||
fn golden_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/docker/workspace_env_golden")
|
||||
.join(format!("{name}.txt"))
|
||||
}
|
||||
|
||||
/// `merge_custom_env_vars` collects a `HashMap`, so the custom variables
|
||||
/// come out in a different order every run (before the extraction as
|
||||
/// well). Sort that one run — between the last managed blank and the
|
||||
/// fingerprint — so the rest of the order is still compared exactly.
|
||||
fn normalise(mut env: Vec<String>) -> Vec<String> {
|
||||
let end = env.iter().position(|e| e.starts_with("TRIPLE_C_CUSTOM_ENV=")).unwrap();
|
||||
let start = env.iter().position(|e| e.starts_with("CLAUDE_CODE_OAUTH_TOKEN=")).unwrap() + 1;
|
||||
env[start..end].sort();
|
||||
env
|
||||
}
|
||||
|
||||
/// The pre-refactor block's output, recorded before the extraction (see
|
||||
/// `workspace_env_golden/`), must be reproduced exactly, order included.
|
||||
#[test]
|
||||
fn workspace_env_matches_what_create_container_built_before_the_extraction() {
|
||||
for c in cases() {
|
||||
let inputs = super::WorkspaceEnvInputs {
|
||||
global_aws: &c.global_aws,
|
||||
global_ollama: &c.global_ollama,
|
||||
global_llamacpp: &c.global_llamacpp,
|
||||
global_openai_compatible: &c.global_openai,
|
||||
global_claude_instructions: c.global_instructions,
|
||||
global_custom_env_vars: &c.global_env,
|
||||
timezone: c.timezone,
|
||||
global_claude_code_settings: c.global_cc.as_ref(),
|
||||
default_git_user_name: c.default_git_name,
|
||||
default_git_user_email: c.default_git_email,
|
||||
resolved_ca: c.ca.as_ref(),
|
||||
shared_claude_token: c.shared_token.as_ref().map(|(t, _)| t.as_str()),
|
||||
};
|
||||
let got: Vec<String> = super::workspace_env(&c.project, &inputs)
|
||||
.into_iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
let want = std::fs::read_to_string(golden_path(c.name)).unwrap();
|
||||
let want: Vec<String> = want
|
||||
.lines()
|
||||
.filter(|l| *l != "[" && *l != "]")
|
||||
.map(|l| {
|
||||
let l = l.trim().trim_end_matches(',');
|
||||
serde_json::from_str::<String>(l).unwrap_or_else(|_| panic!("{l}"))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(normalise(got), normalise(want), "case {}", c.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_MODEL=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=sk-shared-token",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_MODEL=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"CLAUDE_CODE_USE_BEDROCK=1",
|
||||
"AWS_REGION=us-east-1",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=bearerfake",
|
||||
"ANTHROPIC_MODEL=anthropic.claude-x",
|
||||
"DISABLE_PROMPT_CACHING=1",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=priority",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"CLAUDE_CODE_USE_BEDROCK=1",
|
||||
"AWS_REGION=eu-west-1",
|
||||
"AWS_PROFILE=global-profile",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh",
|
||||
"ANTHROPIC_MODEL=global-model",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"CLAUDE_CODE_USE_BEDROCK=1",
|
||||
"AWS_REGION=us-east-1",
|
||||
"ANTHROPIC_MODEL=anthropic.claude-x",
|
||||
"DISABLE_PROMPT_CACHING=1",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=priority",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_MODEL=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=1",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1",
|
||||
"ENABLE_PROMPT_CACHING_1H=1",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"effortLevel\":\"high\",\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false,\"tui\":\"fullscreen\"}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt",
|
||||
"REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt",
|
||||
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_MODEL=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_MODEL=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"BAR=b",
|
||||
"FOO=project",
|
||||
"SPACED=x y",
|
||||
"HOME=reserved",
|
||||
"TRIPLE_C_CUSTOM_ENV=63ce864a8c48a386cb039d4878e3885d5a823d4bb52047ba0e18cb87b9f3035b",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_MODEL=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"MISSION_CONTROL_ENABLED=1",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=global rules\nline two\n\n## Mission Control\n\nThe `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.\n\n### How It Works\n\n- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.\n- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`\n- The methodology docs and project registry live in `/workspace/mission-control/`\n\n### When to Use\n\nWhen working on any project that has a `.flightops/` directory, follow the Flight Control methodology:\n1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage\n2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow\n3. Use Mission Control skills for planning and execution\n\n### Available Skills\n\n| Skill | When to Use |\n|-------|-------------|\n| `/init-project` | Setting up a new project for Flight Control |\n| `/mission` | Defining new work outcomes (days-to-weeks scope) |\n| `/flight` | Creating technical specs from missions (hours-to-days scope) |\n| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |\n| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |\n| `/flight-debrief` | Post-flight analysis after a flight lands |\n| `/mission-debrief` | Post-mission retrospective after completion |\n| `/daily-briefing` | Cross-project status report |\n\n### Key Rules\n\n- **Planning skills produce artifacts only** — never modify source code directly\n- **Phase gates require human confirmation** — missions before flights, flights before legs\n- **Legs are immutable once in-flight** — create new ones instead of modifying\n- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents\n- **Artifacts live in the target project** — not in mission-control\n\nproject rules\n\n## Flight Operations\n\nThis project uses **Flight Control** (bundled with Triple-C) for structured development.\n\n**Before any mission/flight/leg work, read these files in order:**\n1. `.flightops/README.md` — What the flightops directory contains\n2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**\n3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored\n4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)\n\n## Available Port Mappings\nThe following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:\n- Host port 8080 -> Container port 3000 (tcp)\n\n## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relLine truncated
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"allowUnsandboxedCommands\":false,\"enableWeakerNestedSandbox\":true,\"enabled\":true},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"ANTHROPIC_BASE_URL=http://llama:8080",
|
||||
"ANTHROPIC_AUTH_TOKEN=llama.cpp",
|
||||
"ANTHROPIC_MODEL=llama-big",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=llama-big",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=llama-big",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=llama-small",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=llama-big",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"ANTHROPIC_BASE_URL=http://ollama.local:11434",
|
||||
"ANTHROPIC_AUTH_TOKEN=ollama",
|
||||
"ANTHROPIC_MODEL=qwen3",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=tiny",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=qwen3",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
"ANTHROPIC_BASE_URL=https://gw.example/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN=sk-gateway",
|
||||
"ANTHROPIC_MODEL=gpt-x",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=gpt-x",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-x",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-mini",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=gpt-x",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"VPN_SUPPORT_ENABLED=0",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
"GIT_TOKEN=ghp_faketoken",
|
||||
"GIT_USER_NAME=Project User",
|
||||
"GIT_USER_EMAIL=default@example.com",
|
||||
"NODE_EXTRA_CA_CERTS=",
|
||||
"REQUESTS_CA_BUNDLE=",
|
||||
"SSL_CERT_FILE=",
|
||||
"CLAUDE_CODE_USE_BEDROCK=",
|
||||
"AWS_REGION=",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_SESSION_TOKEN=",
|
||||
"AWS_PROFILE=",
|
||||
"AWS_BEARER_TOKEN_BEDROCK=",
|
||||
"AWS_SSO_AUTH_REFRESH_CMD=",
|
||||
"ANTHROPIC_BASE_URL=",
|
||||
"ANTHROPIC_AUTH_TOKEN=",
|
||||
"ANTHROPIC_MODEL=",
|
||||
"DISABLE_PROMPT_CACHING=",
|
||||
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=",
|
||||
"TRIPLE_C_CUSTOM_ENV=",
|
||||
"TZ=Europe/Berlin",
|
||||
"VPN_SUPPORT_ENABLED=1",
|
||||
"TRIPLE_C_PERMISSION_MODE=default",
|
||||
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
|
||||
"CLAUDE_CODE_NO_FLICKER=",
|
||||
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
|
||||
"ENABLE_PROMPT_CACHING_1H=0",
|
||||
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
|
||||
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
|
||||
]
|
||||
@@ -51,8 +51,10 @@ pub async fn poll_file(
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
// A background poll: an open viewer must not keep an idle AWS workspace
|
||||
// up. Opening and saving a file are the user's own actions and count.
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.exec_oneshot_streams_background("claude", cmd)
|
||||
.await?;
|
||||
if code != 0 && code != 4 {
|
||||
return Err(format!(
|
||||
|
||||
@@ -277,20 +277,35 @@ async fn exec_oneshot_raw(
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<OneshotOutput, String> {
|
||||
exec_oneshot_raw_marked(ep, user, cmd, env, limit, false).await
|
||||
}
|
||||
|
||||
/// The agent's background marker (protocol spec, "Activity"): a request that
|
||||
/// carries it never counts as activity for idle stop.
|
||||
pub const BACKGROUND_HEADER: &str = "X-Triple-C-Background";
|
||||
|
||||
async fn exec_oneshot_raw_marked(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
background: bool,
|
||||
) -> 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 mut req = http()
|
||||
.post(ep.url("v1/exec"))
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.json(&body);
|
||||
if background {
|
||||
req = req.header(BACKGROUND_HEADER, "1");
|
||||
}
|
||||
let resp = send(CONTEXT, req).await?;
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
|
||||
@@ -352,6 +367,18 @@ pub async fn exec_oneshot_limited(
|
||||
Ok((out.text(), out.exit_code))
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_limited`] marked as a background poll, so it never keeps
|
||||
/// an idle workspace up (the auth bridge's `/proc/net` reads).
|
||||
pub async fn exec_oneshot_background(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
let out = exec_oneshot_raw_marked(ep, user, cmd, Vec::new(), limit, true).await?;
|
||||
Ok((out.text(), out.exit_code))
|
||||
}
|
||||
|
||||
/// `exec_oneshot_as`: combined output under [`MAX_ONESHOT_OUTPUT`].
|
||||
pub async fn exec_oneshot_as(
|
||||
ep: &AgentEndpoint,
|
||||
@@ -373,6 +400,17 @@ pub async fn exec_oneshot_streams_as(
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_streams_as`] marked as a background poll (the file
|
||||
/// viewer's change check), so it never keeps an idle workspace up.
|
||||
pub async fn exec_oneshot_streams_background(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
let out = exec_oneshot_raw_marked(ep, user, cmd, Vec::new(), MAX_ONESHOT_OUTPUT, true).await?;
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
// ── detached execs, status, resize ───────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -22,6 +22,8 @@ const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000";
|
||||
struct Seen {
|
||||
auth: Vec<String>,
|
||||
exec_bodies: Vec<Value>,
|
||||
/// The background marker of each `/v1/exec`, if any.
|
||||
exec_background: Vec<Option<String>>,
|
||||
detached_bodies: Vec<Value>,
|
||||
status_polls: u32,
|
||||
resizes: Vec<(String, Value)>,
|
||||
@@ -62,7 +64,13 @@ 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());
|
||||
{
|
||||
let mut seen = f.seen.lock().unwrap();
|
||||
seen.exec_bodies.push(body.clone());
|
||||
seen.exec_background.push(
|
||||
h.get("x-triple-c-background").and_then(|v| v.to_str().ok()).map(str::to_string),
|
||||
);
|
||||
}
|
||||
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(),
|
||||
@@ -304,6 +312,33 @@ async fn other_errors_carry_the_agents_message_and_no_token() {
|
||||
assert_eq!(e, "Failed to start exec: boom");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_a_background_poll_carries_the_background_marker() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
t.exec_oneshot_limited("claude", cmd("sh"), 1000).await.unwrap();
|
||||
t.exec_oneshot_background("claude", cmd("sh"), 1000).await.unwrap();
|
||||
let seen = fake.seen.lock().unwrap();
|
||||
assert_eq!(seen.exec_background, [None, Some("1".to_string())]);
|
||||
assert_eq!(seen.exec_bodies[0], seen.exec_bodies[1], "the request is otherwise the same");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_file_viewers_poll_is_a_background_request_and_its_open_and_save_are_not() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
// The 2 s change check, as the viewer runs it.
|
||||
let _ = crate::file_viewer::poll::poll_file(&t, "/workspace/x").await;
|
||||
t.exec_oneshot_streams_background("claude", cmd("sh")).await.unwrap();
|
||||
// What opening or saving goes through: user actions, which count.
|
||||
t.exec_oneshot_streams_as("claude", cmd("sh"), Vec::new()).await.unwrap();
|
||||
let seen = fake.seen.lock().unwrap();
|
||||
assert_eq!(seen.exec_background, [Some("1".to_string()), Some("1".to_string()), None]);
|
||||
let src = |s: &str| s.contains("exec_oneshot_streams_as(");
|
||||
assert!(!src(include_str!("../../file_viewer/poll.rs")), "the poll stays a background request");
|
||||
assert!(src(include_str!("../../file_viewer/resolve.rs")) && src(include_str!("../../file_viewer/write.rs")));
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,797 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput;
|
||||
use aws_sdk_ecs::operation::run_task::RunTaskInput;
|
||||
|
||||
use super::*;
|
||||
use crate::aws::environments::Knowable;
|
||||
use crate::aws::tasks::TaskInfo;
|
||||
use crate::aws::workspace::{AccessPointRef, FileSystemRef, TaskDefinitionRef};
|
||||
use crate::aws::AwsError;
|
||||
use crate::host::lifecycle::ProjectHost;
|
||||
use crate::host::tunnels::tests::{fake_tunnel, FakeTunnel};
|
||||
use crate::models::{AwsPlacement, AwsWorkspaceConfig, StorageMode};
|
||||
|
||||
const REGION: &str = "eu-west-1";
|
||||
const ACCOUNT: &str = "111122223333";
|
||||
const USER: &str = "AROAEXAMPLEID:alice@example.com";
|
||||
|
||||
fn who() -> CallerIdentity {
|
||||
CallerIdentity {
|
||||
account: ACCOUNT.into(),
|
||||
arn: format!("arn:aws:sts::{ACCOUNT}:assumed-role/dev/alice"),
|
||||
user_id: USER.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn aws_project(storage: StorageMode) -> Project {
|
||||
let mut p = Project::new("aws".into(), Vec::new());
|
||||
p.host_id = Some("c1".into());
|
||||
p.aws_placement = Some(AwsPlacement { region: REGION.into(), environment: "dev".into() });
|
||||
p.aws = Some(AwsWorkspaceConfig { storage, ..Default::default() });
|
||||
p
|
||||
}
|
||||
|
||||
fn family(p: &Project) -> String {
|
||||
task_family("dev", &owner_key(REGION, USER), &p.id)
|
||||
}
|
||||
|
||||
fn task_arn(id: &str) -> String {
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}")
|
||||
}
|
||||
|
||||
fn cluster_arn() -> String {
|
||||
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev")
|
||||
}
|
||||
|
||||
fn task(p: &Project, id: &str, last: &str, ready: bool) -> TaskInfo {
|
||||
TaskInfo {
|
||||
arn: task_arn(id),
|
||||
last_status: last.into(),
|
||||
desired_status: if last == "STOPPED" { "STOPPED" } else { "RUNNING" }.into(),
|
||||
health: None,
|
||||
stop_code: (last == "STOPPED").then(|| "EssentialContainerExited".into()),
|
||||
stopped_reason: (last == "STOPPED").then(|| "Essential container exited".into()),
|
||||
task_definition_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:1", family(p)),
|
||||
runtime_id: ready.then(|| "rt-1".into()),
|
||||
exec_agent_running: ready,
|
||||
exit_code: None,
|
||||
owner: Some(USER.into()),
|
||||
project: Some(p.id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn record() -> String {
|
||||
serde_json::json!({
|
||||
"schema": 1, "name": "dev", "template_version": 1,
|
||||
"cluster_arn": cluster_arn(),
|
||||
"security_group_id": "sg-0123456789abcdef0",
|
||||
"subnet_ids": ["subnet-0a", "subnet-0b"],
|
||||
"assign_public_ip": true,
|
||||
"kms_key_arn": format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234-abcd"),
|
||||
"log_group": "/triple-c/dev", "network_mode": "public", "tenancy": "strict"
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// One fake for all three AWS traits and the connector, logging calls.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
calls: Mutex<Vec<String>>,
|
||||
/// ListTasks answers in turn; the last repeats.
|
||||
listed: Mutex<Vec<Vec<String>>>,
|
||||
tasks: Mutex<HashMap<String, TaskInfo>>,
|
||||
/// What RunTask starts.
|
||||
run: Option<TaskInfo>,
|
||||
logs: Vec<String>,
|
||||
role: Option<Knowable>,
|
||||
connect_fails: bool,
|
||||
connect_tokens: Mutex<Vec<String>>,
|
||||
secret_json: Mutex<Option<String>>,
|
||||
/// A losing start rotates the secret's token while this one connects.
|
||||
rotated_by_loser: bool,
|
||||
/// StopTask is refused (expired credentials, say).
|
||||
stop_fails: bool,
|
||||
/// What `on_task` was told, in order.
|
||||
recorded: Mutex<Vec<Option<String>>>,
|
||||
/// `group stream limit` of each log read.
|
||||
log_reads: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn log(&self, s: impl Into<String>) {
|
||||
self.calls.lock().unwrap().push(s.into());
|
||||
}
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
fn called(&self, prefix: &str) -> bool {
|
||||
self.calls().iter().any(|c| c.starts_with(prefix))
|
||||
}
|
||||
fn with_task(self, t: TaskInfo) -> Self {
|
||||
self.tasks.lock().unwrap().insert(t.arn.clone(), t);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskApi for Fake {
|
||||
async fn run_task(&self, _req: RunTaskInput) -> Result<TaskInfo, AwsError> {
|
||||
self.log("run");
|
||||
let t = self.run.clone().expect("a run was expected");
|
||||
self.tasks.lock().unwrap().insert(t.arn.clone(), t.clone());
|
||||
Ok(t)
|
||||
}
|
||||
async fn describe(&self, _c: &str, arns: &[String]) -> Result<Vec<TaskInfo>, AwsError> {
|
||||
let t = self.tasks.lock().unwrap();
|
||||
Ok(arns.iter().filter_map(|a| t.get(a).cloned()).collect())
|
||||
}
|
||||
async fn list_project_tasks(&self, _c: &str, _f: &str) -> Result<Vec<String>, AwsError> {
|
||||
self.log("list");
|
||||
let mut l = self.listed.lock().unwrap();
|
||||
Ok(if l.len() > 1 { l.remove(0) } else { l.first().cloned().unwrap_or_default() })
|
||||
}
|
||||
async fn stop_task(&self, _c: &str, arn: &str, reason: &str) -> Result<(), AwsError> {
|
||||
self.log(format!("stop {arn} {reason}"));
|
||||
if self.stop_fails {
|
||||
return Err(AwsError::Other { message: "ExpiredToken".into() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn log_tail(&self, g: &str, st: &str, l: i32) -> Result<Vec<String>, AwsError> {
|
||||
self.log_reads.lock().unwrap().push(format!("{g} {st} {l}"));
|
||||
Ok(self.logs.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceApi for Fake {
|
||||
async fn find_file_system(&self, _t: &str) -> Result<Option<FileSystemRef>, AwsError> {
|
||||
Ok(Some(FileSystemRef { id: "fs-1".into(), owner: Some(USER.into()) }))
|
||||
}
|
||||
async fn access_points(&self, _fs: &str) -> Result<Vec<AccessPointRef>, AwsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn create_access_point(
|
||||
&self,
|
||||
_fs: &str,
|
||||
_root: &str,
|
||||
_tags: &[(String, String)],
|
||||
) -> Result<String, AwsError> {
|
||||
self.log("create-ap");
|
||||
Ok("fsap-new".into())
|
||||
}
|
||||
async fn put_project_secret(
|
||||
&self,
|
||||
_name: &str,
|
||||
_kms: &str,
|
||||
json: &str,
|
||||
_tags: &[(String, String)],
|
||||
) -> Result<String, AwsError> {
|
||||
self.log("put-secret");
|
||||
*self.secret_json.lock().unwrap() = Some(json.into());
|
||||
Ok(format!("arn:aws:secretsmanager:{REGION}:{ACCOUNT}:secret:triple-c/dev/x-AbCdEf"))
|
||||
}
|
||||
async fn task_definition_revisions(&self, _f: &str) -> Result<Vec<String>, AwsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn describe_task_definition(
|
||||
&self,
|
||||
_arn: &str,
|
||||
) -> Result<Option<TaskDefinitionRef>, AwsError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn register_task_definition(
|
||||
&self,
|
||||
req: RegisterTaskDefinitionInput,
|
||||
) -> Result<String, AwsError> {
|
||||
self.log("register");
|
||||
Ok(format!(
|
||||
"arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:1",
|
||||
req.family().unwrap()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeApi for Fake {
|
||||
async fn environment_record(&self, env: &str) -> Result<Option<String>, AwsError> {
|
||||
self.log(format!("record {env}"));
|
||||
Ok(Some(record()))
|
||||
}
|
||||
async fn role_exists(&self, _name: &str) -> Result<Knowable, AwsError> {
|
||||
self.log("role");
|
||||
Ok(self.role.clone().unwrap_or(Knowable::Yes))
|
||||
}
|
||||
async fn secret_string(&self, _name: &str) -> Result<String, AwsError> {
|
||||
self.log("get-secret");
|
||||
Ok(self
|
||||
.secret_json
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| r#"{"TRIPLEC_AGENT_TOKEN":"token-of-the-running-task"}"#.into()))
|
||||
}
|
||||
async fn access_point_state(&self, id: &str) -> Result<Option<String>, AwsError> {
|
||||
self.log(format!("ap-state {id}"));
|
||||
Ok(Some("available".into()))
|
||||
}
|
||||
async fn delete_secret(&self, _name: &str) -> Result<(), AwsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_access_point(&self, _id: &str) -> Result<(), AwsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Connector for Fake {
|
||||
async fn connect(
|
||||
&self,
|
||||
_task: &TaskInfo,
|
||||
token: &str,
|
||||
) -> Result<(Box<dyn TunnelHandle>, AgentEndpoint), String> {
|
||||
self.log("connect");
|
||||
self.connect_tokens.lock().unwrap().push(token.into());
|
||||
if self.rotated_by_loser {
|
||||
let mut j = self.secret_json.lock().unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(j.as_deref().unwrap()).unwrap();
|
||||
v["TRIPLEC_AGENT_TOKEN"] = "the-losers-token".into();
|
||||
*j = Some(v.to_string());
|
||||
}
|
||||
if self.connect_fails {
|
||||
return Err("session-manager-plugin was not found".into());
|
||||
}
|
||||
Ok((
|
||||
Box::new(FakeTunnel(std::sync::Arc::new(AtomicUsize::new(0)))),
|
||||
AgentEndpoint::loopback(61100, token),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs<'a>(
|
||||
p: &'a Project,
|
||||
who: &'a CallerIdentity,
|
||||
on_task: &'a (dyn Fn(Option<&TaskRecord>) + Send + Sync),
|
||||
) -> StartInputs<'a> {
|
||||
StartInputs {
|
||||
on_task,
|
||||
project: p,
|
||||
who,
|
||||
env_pairs: &[],
|
||||
files: &[],
|
||||
image: REGISTRY_IMAGE,
|
||||
wait: WaitTiming {
|
||||
interval: Duration::from_millis(1),
|
||||
timeout: Duration::from_millis(30),
|
||||
},
|
||||
access_point_wait: PollTiming {
|
||||
interval: Duration::from_millis(1),
|
||||
timeout: Duration::from_millis(30),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(fake: &Fake, p: &Project) -> (Result<Started, String>, Vec<String>) {
|
||||
let w = who();
|
||||
let steps = Mutex::new(Vec::new());
|
||||
let progress = |m: &str| steps.lock().unwrap().push(m.to_string());
|
||||
let on_task = |r: Option<&TaskRecord>| {
|
||||
fake.recorded.lock().unwrap().push(r.map(|r| r.task_arn.clone()));
|
||||
};
|
||||
let r = start_core(fake, fake, fake, fake, &inputs(p, &w, &on_task), &progress).await;
|
||||
(r, steps.into_inner().unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_first_start_prepares_runs_waits_and_connects_in_that_order() {
|
||||
let p = aws_project(StorageMode::Hybrid);
|
||||
let fake = Fake { run: Some(task(&p, "new", "RUNNING", true)), ..Default::default() };
|
||||
let (r, steps) = start(&fake, &p).await;
|
||||
let started = r.unwrap();
|
||||
assert!(started.created);
|
||||
assert_eq!(started.access_point_id.as_deref(), Some("fsap-new"));
|
||||
assert_eq!(started.task.arn, task_arn("new"));
|
||||
assert_eq!(
|
||||
fake.calls(),
|
||||
vec![
|
||||
"record dev", "role", "list", "create-ap", "put-secret", "register",
|
||||
"ap-state fsap-new", "list", "run", "connect", "get-secret"
|
||||
],
|
||||
"look for a live task before rotating the token; the access point is available before RunTask"
|
||||
);
|
||||
assert_eq!(steps, vec![PROGRESS_READING, PROGRESS_PREPARING, PROGRESS_STARTING, PROGRESS_WAITING, PROGRESS_CONNECTING]);
|
||||
assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("new"))], "recorded before the wait");
|
||||
// The connector got the token the secret was just written with.
|
||||
let secret: serde_json::Value =
|
||||
serde_json::from_str(fake.secret_json.lock().unwrap().as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
fake.connect_tokens.lock().unwrap()[0],
|
||||
secret["TRIPLEC_AGENT_TOKEN"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_running_task_is_attached_with_its_own_token_and_nothing_is_rewritten() {
|
||||
let p = aws_project(StorageMode::Hybrid);
|
||||
let live = task(&p, "live", "RUNNING", true);
|
||||
let fake = Fake {
|
||||
listed: Mutex::new(vec![vec![live.arn.clone()]]),
|
||||
..Default::default()
|
||||
}
|
||||
.with_task(live);
|
||||
let (r, steps) = start(&fake, &p).await;
|
||||
let started = r.unwrap();
|
||||
assert!(!started.created);
|
||||
assert_eq!(started.access_point_id, None);
|
||||
assert_eq!(fake.calls(), vec!["record dev", "role", "list", "get-secret", "connect"]);
|
||||
assert_eq!(fake.connect_tokens.lock().unwrap()[0], "token-of-the-running-task");
|
||||
assert_eq!(steps, vec![PROGRESS_READING, PROGRESS_WAITING, PROGRESS_CONNECTING]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn losing_the_run_race_is_a_clear_error_and_nothing_is_stopped() {
|
||||
let p = aws_project(StorageMode::Ephemeral);
|
||||
let other = task(&p, "other", "RUNNING", true);
|
||||
let fake = Fake {
|
||||
// Nothing at the first look; a concurrent start's task at the second.
|
||||
listed: Mutex::new(vec![vec![], vec![other.arn.clone()]]),
|
||||
..Default::default()
|
||||
}
|
||||
.with_task(other);
|
||||
let (r, _) = start(&fake, &p).await;
|
||||
assert_eq!(r.err().as_deref(), Some(START_RACE));
|
||||
assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("other"))], "the live task is recorded");
|
||||
assert!(!fake.called("run") && !fake.called("stop") && !fake.called("connect"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_task_that_stops_while_starting_is_reported_with_its_log() {
|
||||
let p = aws_project(StorageMode::Ephemeral);
|
||||
let fake = Fake {
|
||||
run: Some(task(&p, "dies", "STOPPED", false)),
|
||||
logs: vec!["entrypoint: cannot decode TRIPLEC_FILES".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let (r, _) = start(&fake, &p).await;
|
||||
let e = r.err().unwrap();
|
||||
assert!(e.contains("Essential container exited") && e.contains("cannot decode"), "{e}");
|
||||
assert!(!fake.called("connect"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_task_that_never_gets_ready_is_stopped_and_its_log_shown() {
|
||||
let p = aws_project(StorageMode::Ephemeral);
|
||||
let fake = Fake {
|
||||
run: Some(task(&p, "slow", "PENDING", false)),
|
||||
logs: vec!["pulling image".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let (r, _) = start(&fake, &p).await;
|
||||
let e = r.err().unwrap();
|
||||
assert!(e.contains("did not become ready") && e.contains("pulling image"), "{e}");
|
||||
assert!(fake.calls().contains(&format!("stop {} {STOP_REASON}", task_arn("slow"))));
|
||||
assert_eq!(
|
||||
*fake.recorded.lock().unwrap(),
|
||||
vec![Some(task_arn("slow")), None],
|
||||
"the ARN is stored before the wait, and forgotten once the task is stopped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tunnel_failure_stops_a_task_this_start_ran_but_never_an_attached_one() {
|
||||
let p = aws_project(StorageMode::Ephemeral);
|
||||
let fake = Fake {
|
||||
run: Some(task(&p, "new", "RUNNING", true)),
|
||||
connect_fails: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (r, _) = start(&fake, &p).await;
|
||||
assert!(r.err().unwrap().contains("session-manager-plugin"));
|
||||
assert!(fake.calls().contains(&format!("stop {} {STOP_REASON}", task_arn("new"))));
|
||||
|
||||
let live = task(&p, "live", "RUNNING", true);
|
||||
let attached = Fake {
|
||||
listed: Mutex::new(vec![vec![live.arn.clone()]]),
|
||||
connect_fails: true,
|
||||
..Default::default()
|
||||
}
|
||||
.with_task(live);
|
||||
let (r, _) = start(&attached, &p).await;
|
||||
assert!(r.is_err());
|
||||
assert!(!attached.called("stop"));
|
||||
assert_eq!(
|
||||
*attached.recorded.lock().unwrap(),
|
||||
vec![Some(task_arn("live"))],
|
||||
"an attached task stays recorded: it is still running"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_user_who_has_not_joined_is_told_before_anything_is_written() {
|
||||
let p = aws_project(StorageMode::Hybrid);
|
||||
let fake = Fake { role: Some(Knowable::No), ..Default::default() };
|
||||
let (r, _) = start(&fake, &p).await;
|
||||
assert!(r.err().unwrap().contains("not joined"));
|
||||
assert_eq!(fake.calls(), vec!["record dev", "role"]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────── inputs
|
||||
|
||||
#[test]
|
||||
fn aws_runs_registry_images_only() {
|
||||
let mut s = AppSettings { image_source: ImageSource::LocalBuild, ..Default::default() };
|
||||
assert_eq!(aws_image(&s).unwrap(), REGISTRY_IMAGE);
|
||||
s.image_source = ImageSource::Registry;
|
||||
assert_eq!(aws_image(&s).unwrap(), REGISTRY_IMAGE);
|
||||
s.image_source = ImageSource::Custom;
|
||||
for good in ["registry.example.com/team/img:1", "ghcr.io/x/y", "user/repo:tag", "reg:5000/a"] {
|
||||
s.custom_image_name = Some(good.into());
|
||||
assert_eq!(aws_image(&s).unwrap(), good);
|
||||
}
|
||||
for bad in ["", "triple-c:latest", "ubuntu", "localhost/x", "127.0.0.1:5000/x", "a /b", "/x"] {
|
||||
s.custom_image_name = Some(bad.into());
|
||||
assert!(aws_image(&s).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sandbox_is_forced_off_on_aws() {
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.sandbox_mode_enabled = true;
|
||||
let (pairs, _) = aws_env(&p, &AppSettings::default()).unwrap();
|
||||
let settings = &pairs.iter().find(|(k, _)| k == "CLAUDE_CODE_SETTINGS_JSON").unwrap().1;
|
||||
let v: serde_json::Value = serde_json::from_str(settings).unwrap();
|
||||
assert_eq!(v["sandbox"]["enabled"], false, "{settings}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_for_the_secret_come_from_the_ssh_folder_without_leaving_it() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
let ssh = base.path().join("ssh");
|
||||
std::fs::create_dir(&ssh).unwrap();
|
||||
std::fs::write(ssh.join("id_ed25519"), b"KEY").unwrap();
|
||||
std::fs::write(ssh.join("config"), b"Host *").unwrap();
|
||||
std::fs::create_dir(ssh.join("sub")).unwrap();
|
||||
std::fs::write(ssh.join("sub/nested"), b"x").unwrap();
|
||||
std::fs::write(base.path().join("outside"), b"SECRET-OUTSIDE").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(base.path().join("outside"), ssh.join("escape")).unwrap();
|
||||
std::os::unix::fs::symlink(ssh.join("config"), ssh.join("alias")).unwrap();
|
||||
}
|
||||
let got = workspace_files(Some(ssh.to_str().unwrap()), None).unwrap();
|
||||
let names: Vec<&str> = got.iter().map(|(k, _)| k.as_str()).collect();
|
||||
#[cfg(unix)]
|
||||
assert_eq!(names, vec!["ssh/alias", "ssh/config", "ssh/id_ed25519"]);
|
||||
#[cfg(not(unix))]
|
||||
assert_eq!(names, vec!["ssh/config", "ssh/id_ed25519"]);
|
||||
assert!(got.iter().all(|(_, v)| v != b"SECRET-OUTSIDE"));
|
||||
|
||||
std::fs::write(ssh.join("huge"), vec![b'x'; MAX_FILE_BYTES as usize + 1]).unwrap();
|
||||
let e = workspace_files(Some(ssh.to_str().unwrap()), None).unwrap_err();
|
||||
assert!(e.contains("huge") && e.contains("64 KiB"), "{e}");
|
||||
|
||||
assert!(workspace_files(Some("/"), None).is_err(), "a filesystem root is refused");
|
||||
assert!(workspace_files(None, None).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_files_are_what_docker_would_mount() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
let dir = base.path().join("ca");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::write(dir.join("corp.pem"), b"PEM").unwrap();
|
||||
std::fs::write(dir.join("README"), b"not a cert").unwrap();
|
||||
let ca = crate::docker::ca_certs::resolve(Some(dir.to_str().unwrap())).unwrap().unwrap();
|
||||
let got = workspace_files(None, Some(&ca)).unwrap();
|
||||
assert_eq!(got, vec![("ca/corp.pem".to_string(), b"PEM".to_vec())]);
|
||||
|
||||
let one = base.path().join("Root CA.pem");
|
||||
std::fs::write(&one, b"ONE").unwrap();
|
||||
let ca = crate::docker::ca_certs::resolve(Some(one.to_str().unwrap())).unwrap().unwrap();
|
||||
let got = workspace_files(None, Some(&ca)).unwrap();
|
||||
assert_eq!(got, vec![("ca/Root_CA.crt".to_string(), b"ONE".to_vec())]);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────── targets and reconcile
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_aws_projects_target_is_its_registered_tunnel() {
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.container_id = Some("http://127.0.0.1:61200/".into());
|
||||
assert_eq!(ProjectHost::for_project(&p), ProjectHost::Aws);
|
||||
assert_eq!(ContainerTarget::for_project(&p), None, "no tunnel, no target, whatever is stored");
|
||||
tunnels::register(&p.id, fake_tunnel(61200, std::sync::Arc::new(AtomicUsize::new(0))));
|
||||
assert_eq!(
|
||||
ContainerTarget::for_project(&p),
|
||||
Some(ContainerTarget::Agent(AgentEndpoint::loopback(61200, "tok")))
|
||||
);
|
||||
assert_eq!(
|
||||
ContainerTarget::for_project_waiting(&p).await,
|
||||
ContainerTarget::for_project(&p)
|
||||
);
|
||||
tunnels::close(&p.id).await;
|
||||
|
||||
let local = Project::new("local".into(), Vec::new());
|
||||
assert_eq!(ProjectHost::for_project(&local), ProjectHost::Docker);
|
||||
}
|
||||
|
||||
fn find(p: &Project) -> FindTask {
|
||||
FindTask::new(p, REGION, USER)
|
||||
}
|
||||
|
||||
fn store_with(p: &Project) -> (tempfile::TempDir, ProjectsStore) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = ProjectsStore::over_file_for_test(dir.path().join("projects.json"), vec![p.clone()]);
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_marks_a_project_whose_task_stopped_as_stopped() {
|
||||
let mut p = aws_project(StorageMode::Hybrid);
|
||||
p.status = ProjectStatus::Running;
|
||||
{
|
||||
let a = p.aws.as_mut().unwrap();
|
||||
a.task_arn = Some(task_arn("gone"));
|
||||
a.access_point_id = Some("fsap-1".into());
|
||||
a.task_definition_arn = Some("arn:td:1".into());
|
||||
}
|
||||
let fake = Fake::default().with_task(task(&p, "gone", "STOPPED", false));
|
||||
let (_d, store) = store_with(&p);
|
||||
assert_eq!(reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone);
|
||||
let after = store.get(&p.id).unwrap();
|
||||
assert_eq!(after.status, ProjectStatus::Stopped);
|
||||
let a = after.aws.unwrap();
|
||||
assert_eq!(a.task_arn, None, "a stopped task's ARN is cleared");
|
||||
assert_eq!(a.access_point_id.as_deref(), Some("fsap-1"), "the data's access point stays");
|
||||
|
||||
// ECS has forgotten it: also gone.
|
||||
let forgotten = Fake::default();
|
||||
let (_d, store) = store_with(&p);
|
||||
assert_eq!(reconcile_project(&forgotten, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone);
|
||||
assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_keeps_a_project_with_a_live_task_running() {
|
||||
let mut p = aws_project(StorageMode::Hybrid);
|
||||
p.status = ProjectStatus::Running;
|
||||
p.aws.as_mut().unwrap().task_arn = Some(task_arn("up"));
|
||||
let fake = Fake::default().with_task(task(&p, "up", "RUNNING", true));
|
||||
let (_d, store) = store_with(&p);
|
||||
assert!(matches!(reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), Reconciled::Live(_)));
|
||||
assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_watch_reports_a_running_aws_project_as_its_stored_target_and_confirms_a_recorded_stop() {
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.status = ProjectStatus::Running;
|
||||
p.container_id = Some("http://127.0.0.1:61300/".into());
|
||||
p.aws.as_mut().unwrap().task_arn = Some(task_arn("w1"));
|
||||
let w = watched_task(&p).expect("watched");
|
||||
assert_eq!(w.target.id(), "http://127.0.0.1:61300/", "settle's running_on matches the stored id");
|
||||
assert_eq!(w.group.cluster_arn, cluster_arn());
|
||||
assert_eq!(w.group.connection_id, "c1");
|
||||
assert!(!w.target.has_exited().await, "nothing recorded yet");
|
||||
record_stopped(std::slice::from_ref(&p), &[(task_arn("w1"), StopKind::Idle)]);
|
||||
assert!(w.target.has_exited().await);
|
||||
assert_eq!(crate::host::tunnels::stop_kind(w.target.id()), Some(StopKind::Idle));
|
||||
|
||||
let mut stopped = p.clone();
|
||||
stopped.status = ProjectStatus::Stopped;
|
||||
assert!(watched_task(&stopped).is_none(), "a Stopped project is not polled again");
|
||||
let local = Project::new("l".into(), Vec::new());
|
||||
assert!(watched_task(&local).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_winner_of_a_start_race_puts_its_token_back_in_the_secret() {
|
||||
let p = aws_project(StorageMode::Ephemeral);
|
||||
let fake = Fake {
|
||||
run: Some(task(&p, "new", "RUNNING", true)),
|
||||
rotated_by_loser: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (r, _) = start(&fake, &p).await;
|
||||
r.unwrap();
|
||||
let ours = fake.connect_tokens.lock().unwrap()[0].clone();
|
||||
let calls = fake.calls();
|
||||
assert_eq!(&calls[calls.len() - 3..], ["connect", "get-secret", "put-secret"]);
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(fake.secret_json.lock().unwrap().as_deref().unwrap()).unwrap();
|
||||
assert_eq!(v["TRIPLEC_AGENT_TOKEN"], ours.as_str());
|
||||
assert!(v.get("TRIPLEC_FILES").is_some(), "the rest of the payload is kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unchanged_secret_is_not_rewritten_after_a_start() {
|
||||
let p = aws_project(StorageMode::Ephemeral);
|
||||
let fake = Fake { run: Some(task(&p, "new", "RUNNING", true)), ..Default::default() };
|
||||
start(&fake, &p).await.0.unwrap();
|
||||
assert_eq!(fake.calls().iter().filter(|c| *c == "put-secret").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_token_says_to_restart_the_workspace() {
|
||||
assert_eq!(
|
||||
refusal_message("Agent health check failed: the agent rejected the credentials (401)"),
|
||||
TOKEN_MISMATCH
|
||||
);
|
||||
assert!(refusal_message("Agent health check failed: agent returned HTTP 500").contains("500"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_finds_a_task_whose_arn_was_never_stored() {
|
||||
// The app quit during "Waiting for the task" in a build that stored the
|
||||
// ARN only at the end: the task is found by family and tags.
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.status = ProjectStatus::Running;
|
||||
let live = task(&p, "orphan", "RUNNING", true);
|
||||
let fake = Fake { listed: Mutex::new(vec![vec![live.arn.clone()]]), ..Default::default() }
|
||||
.with_task(live);
|
||||
let (_d, store) = store_with(&p);
|
||||
assert!(matches!(
|
||||
reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(),
|
||||
Reconciled::Live(_)
|
||||
));
|
||||
let after = store.get(&p.id).unwrap();
|
||||
assert_eq!(after.status, ProjectStatus::Running);
|
||||
assert_eq!(after.aws.unwrap().task_arn, Some(task_arn("orphan")), "now stored");
|
||||
|
||||
let nothing = Fake::default();
|
||||
let (_d, store) = store_with(&p);
|
||||
assert_eq!(reconcile_project(¬hing, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone);
|
||||
assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reattach_registers_only_while_the_project_still_runs_that_task() {
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.status = ProjectStatus::Running;
|
||||
p.aws.as_mut().unwrap().task_arn = Some(task_arn("t"));
|
||||
let (_d, store) = store_with(&p);
|
||||
assert!(reattach_still_wanted(&store, &p.id, &task_arn("t")));
|
||||
assert!(!reattach_still_wanted(&store, &p.id, &task_arn("other")));
|
||||
store.update_status(&p.id, ProjectStatus::Stopping).unwrap();
|
||||
assert!(!reattach_still_wanted(&store, &p.id, &task_arn("t")));
|
||||
store.remove(&p.id).unwrap();
|
||||
assert!(!reattach_still_wanted(&store, &p.id, &task_arn("t")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_records_its_task_and_keeps_the_stored_handles_it_does_not_know() {
|
||||
let mut p = aws_project(StorageMode::Hybrid);
|
||||
p.aws.as_mut().unwrap().access_point_id = Some("fsap-old".into());
|
||||
let (_d, store) = store_with(&p);
|
||||
record_task(
|
||||
&store,
|
||||
&p.id,
|
||||
Some(&TaskRecord { task_arn: "arn:t".into(), access_point_id: None, task_definition_arn: Some("arn:td:2".into()) }),
|
||||
);
|
||||
let a = store.get(&p.id).unwrap().aws.unwrap();
|
||||
assert_eq!((a.task_arn.as_deref(), a.access_point_id.as_deref(), a.task_definition_arn.as_deref()),
|
||||
(Some("arn:t"), Some("fsap-old"), Some("arn:td:2")));
|
||||
record_task(&store, &p.id, None);
|
||||
let a = store.get(&p.id).unwrap().aws.unwrap();
|
||||
assert_eq!((a.task_arn, a.access_point_id.as_deref()), (None, Some("fsap-old")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_task_that_could_not_be_stopped_after_a_failed_start_stays_recorded() {
|
||||
let p = aws_project(StorageMode::Ephemeral);
|
||||
let fake = Fake {
|
||||
run: Some(task(&p, "slow", "PENDING", false)),
|
||||
stop_fails: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(start(&fake, &p).await.0.is_err());
|
||||
assert!(fake.called("stop"));
|
||||
assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("slow"))], "still findable");
|
||||
|
||||
let fake = Fake {
|
||||
run: Some(task(&p, "new", "RUNNING", true)),
|
||||
connect_fails: true,
|
||||
stop_fails: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(start(&fake, &p).await.0.is_err());
|
||||
assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("new"))]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_task_log_reads_the_tasks_stream_in_the_records_log_group() {
|
||||
let p = aws_project(StorageMode::Hybrid);
|
||||
let fake = Fake { logs: vec!["line 1".into(), "line 2".into()], ..Default::default() };
|
||||
let lines = task_log_lines(&fake, &fake, &p, &who(), REGION, &task_arn("abc"), 20).await.unwrap();
|
||||
assert_eq!(lines, vec!["line 1", "line 2"]);
|
||||
let prefix = log_stream_prefix(&owner_key(REGION, USER), &p.id);
|
||||
assert_eq!(
|
||||
*fake.log_reads.lock().unwrap(),
|
||||
vec![format!("/triple-c/dev {prefix}/workspace/abc 20")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_task_is_the_stored_one_else_the_last_one_a_start_saw() {
|
||||
let mut p = aws_project(StorageMode::Hybrid);
|
||||
p.id = "log-task-test-project".into();
|
||||
assert_eq!(log_task_arn(&p), None);
|
||||
let (_d, store) = store_with(&p);
|
||||
record_task(
|
||||
&store,
|
||||
&p.id,
|
||||
Some(&TaskRecord { task_arn: "arn:first".into(), access_point_id: None, task_definition_arn: None }),
|
||||
);
|
||||
record_task(&store, &p.id, None);
|
||||
assert_eq!(log_task_arn(&p).as_deref(), Some("arn:first"), "a cleared ARN still has a log");
|
||||
p.aws.as_mut().unwrap().task_arn = Some("arn:current".into());
|
||||
assert_eq!(log_task_arn(&p).as_deref(), Some("arn:current"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_falls_back_to_a_newer_live_task_when_the_stored_one_stopped() {
|
||||
// m3: another machine restarted the project; the stored ARN is history.
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.status = ProjectStatus::Running;
|
||||
p.aws.as_mut().unwrap().task_arn = Some(task_arn("old"));
|
||||
let newer = task(&p, "newer", "RUNNING", true);
|
||||
let fake = Fake { listed: Mutex::new(vec![vec![newer.arn.clone()]]), ..Default::default() }
|
||||
.with_task(task(&p, "old", "STOPPED", false))
|
||||
.with_task(newer);
|
||||
let (_d, store) = store_with(&p);
|
||||
assert!(matches!(
|
||||
reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(),
|
||||
Reconciled::Live(t) if t.arn == task_arn("newer")
|
||||
));
|
||||
let after = store.get(&p.id).unwrap();
|
||||
assert_eq!(after.status, ProjectStatus::Running);
|
||||
assert_eq!(after.aws.unwrap().task_arn, Some(task_arn("newer")));
|
||||
|
||||
// ECS forgot the stored one and nothing else runs: Gone, after looking.
|
||||
let fake = Fake::default();
|
||||
let (_d, store) = store_with(&p);
|
||||
assert_eq!(reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone);
|
||||
assert!(fake.called("list"), "it looked for another task first");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_forgets_a_stale_stop_on_the_unattached_id_before_reusing_it() {
|
||||
use crate::host::tunnels;
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.status = ProjectStatus::Running;
|
||||
p.aws.as_mut().unwrap().task_arn = Some(task_arn("u2"));
|
||||
let placeholder = tunnels::unattached_id(&p.id);
|
||||
// A past unattached spell's task was seen stopped under this id.
|
||||
tunnels::mark_stopped(&placeholder, StopKind::Spot);
|
||||
p.container_id = Some("http://127.0.0.1:61400/".into());
|
||||
assert_eq!(prepare_unattached(&p), Some(placeholder.clone()));
|
||||
assert!(!tunnels::is_stopped(&placeholder), "the live task must not read as stopped");
|
||||
// Already on the placeholder: nothing to store, the mark still goes.
|
||||
p.container_id = Some(placeholder.clone());
|
||||
tunnels::mark_stopped(&placeholder, StopKind::Spot);
|
||||
assert_eq!(prepare_unattached(&p), None);
|
||||
assert!(!tunnels::is_stopped(&placeholder));
|
||||
assert_eq!(prepare_unattached(&Project::new("l".into(), Vec::new())), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unattached_aws_project_gets_a_target_id_no_tunnel_can_have() {
|
||||
// m9: the last run's loopback port may belong to another project's
|
||||
// tunnel now.
|
||||
let mut p = aws_project(StorageMode::Ephemeral);
|
||||
p.status = ProjectStatus::Running;
|
||||
p.container_id = Some("http://127.0.0.1:61400/".into());
|
||||
p.aws.as_mut().unwrap().task_arn = Some(task_arn("u1"));
|
||||
let id = unattached_container_id(&p).expect("replaced");
|
||||
assert_eq!(id, format!("http://unattached.invalid/{}/", p.id));
|
||||
p.container_id = Some(id.clone());
|
||||
assert_eq!(unattached_container_id(&p), None, "idempotent");
|
||||
// The watch still reports it, by that id, and a registered tunnel on the
|
||||
// old port can't be confused with it.
|
||||
let w = watched_task(&p).expect("still watched");
|
||||
assert_eq!(w.target.id(), id);
|
||||
p.container_id = None;
|
||||
assert_eq!(unattached_container_id(&p), None, "nothing stored, nothing to replace");
|
||||
let local = Project::new("l".into(), Vec::new());
|
||||
assert_eq!(unattached_container_id(&local), None);
|
||||
}
|
||||
@@ -17,12 +17,15 @@ use super::ContainerTarget;
|
||||
use crate::docker;
|
||||
use crate::models::{AppSettings, Project};
|
||||
use crate::models::container_config;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
/// 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).
|
||||
/// The host a project runs on: the local Docker daemon, or an ECS Fargate
|
||||
/// task in an AWS environment (`aws_host.rs`; the connection, region and
|
||||
/// environment come from the project).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProjectHost {
|
||||
Docker,
|
||||
Aws,
|
||||
}
|
||||
|
||||
/// An agent target is a way *into* a container, not a handle on its
|
||||
@@ -41,8 +44,12 @@ pub struct DiscardLeftovers {
|
||||
}
|
||||
|
||||
impl ProjectHost {
|
||||
pub fn for_project(_project: &Project) -> Self {
|
||||
ProjectHost::Docker
|
||||
pub fn for_project(project: &Project) -> Self {
|
||||
if project.is_aws() {
|
||||
ProjectHost::Aws
|
||||
} else {
|
||||
ProjectHost::Docker
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure the project's container exists, matches the project's
|
||||
@@ -50,16 +57,20 @@ impl ProjectHost {
|
||||
///
|
||||
/// `progress` receives the same human-readable steps the start view has
|
||||
/// always shown. Failure leaves the caller to reset the project status.
|
||||
/// `store`: the AWS host records a task's ARN in it as soon as the task
|
||||
/// exists, so a crash during the long wait leaves it findable.
|
||||
pub async fn ensure_started(
|
||||
&self,
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
store: &ProjectsStore,
|
||||
progress: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<ContainerTarget, String> {
|
||||
match self {
|
||||
ProjectHost::Docker => docker_ensure_started(project, settings, progress)
|
||||
.await
|
||||
.map(ContainerTarget::docker),
|
||||
ProjectHost::Aws => super::aws_host::ensure_started(project, settings, store, progress).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,24 +83,66 @@ impl ProjectHost {
|
||||
ProjectHost::Docker => Ok(docker::find_existing_container(project)
|
||||
.await?
|
||||
.map(ContainerTarget::docker)),
|
||||
// The tunnel registry: an AWS project's target exists while its
|
||||
// tunnel is open.
|
||||
ProjectHost::Aws => Ok(ContainerTarget::for_project(project)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the container behind `target`. For an agent target that is the
|
||||
/// ECS task behind a registered tunnel (`StopTask`, reason "Stopped from
|
||||
/// Triple-C"), and the tunnel is closed.
|
||||
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")),
|
||||
ContainerTarget::Agent(ep) => super::aws_host::stop_endpoint(ep)
|
||||
.await
|
||||
.unwrap_or_else(|| Err(AGENT_LIFECYCLE.replace("{op}", "Stopping"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a project's container whether or not the app holds a target for
|
||||
/// it. Docker: the stored container. AWS: the tunnel's task, else the
|
||||
/// stored task ARN, else the project's live task — a project whose
|
||||
/// tunnel could not be re-opened must still be stoppable.
|
||||
pub async fn stop_project(&self, project: &Project, settings: &AppSettings) -> Result<(), String> {
|
||||
match self {
|
||||
ProjectHost::Docker => match ContainerTarget::for_project(project) {
|
||||
Some(t) => self.stop(&t).await,
|
||||
None => Ok(()),
|
||||
},
|
||||
ProjectHost::Aws => super::aws_host::stop_project(project, settings).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop (errors ignored — a stopped container is the goal) and remove.
|
||||
/// An agent target is stopped; its resources go with
|
||||
/// [`Self::remove_aws_resources`].
|
||||
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")),
|
||||
ContainerTarget::Agent(ep) => super::aws_host::stop_endpoint(ep)
|
||||
.await
|
||||
.unwrap_or_else(|| Err(AGENT_LIFECYCLE.replace("{op}", "Removing"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// AWS: stop the task and remove the project's resources — task
|
||||
/// definition revisions, the secret (deleted at once) and, only
|
||||
/// with `discard_data`, the EFS access point (not its files). Docker: nothing (its state
|
||||
/// goes with [`Self::discard_project_state`]).
|
||||
pub async fn remove_aws_resources(
|
||||
&self,
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
discard_data: bool,
|
||||
) -> Result<(), String> {
|
||||
match self {
|
||||
ProjectHost::Docker => Ok(()),
|
||||
ProjectHost::Aws => super::aws_host::remove_resources(project, settings, discard_data).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +160,8 @@ impl ProjectHost {
|
||||
left.volumes = docker::remove_project_volumes(project).await;
|
||||
left
|
||||
}
|
||||
// Removed by `remove_aws_resources`, which needs the settings.
|
||||
ProjectHost::Aws => DiscardLeftovers::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +173,7 @@ impl ProjectHost {
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
}
|
||||
ProjectHost::Aws => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +333,8 @@ mod tests {
|
||||
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 store_dir = tempfile::tempdir().expect("temp dir");
|
||||
let store = ProjectsStore::over_file_for_test(store_dir.path().join("projects.json"), Vec::new());
|
||||
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());
|
||||
@@ -289,7 +347,7 @@ mod tests {
|
||||
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?;
|
||||
let first = host.ensure_started(&project, &settings, &store, &progress).await?;
|
||||
check(first.is_running().await?, "running after start")?;
|
||||
let (out, code) = first
|
||||
.exec_oneshot_env_status(vec!["id".into(), "-un".into()], vec![])
|
||||
@@ -301,7 +359,7 @@ mod tests {
|
||||
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?;
|
||||
let second = host.ensure_started(&project, &settings, &store, &progress).await?;
|
||||
check(second == first, "restart reuses the container")?;
|
||||
check(second.is_running().await?, "running after restart")?;
|
||||
|
||||
|
||||
@@ -28,8 +28,11 @@
|
||||
//! may still do so.
|
||||
|
||||
pub mod agent;
|
||||
pub mod aws_host;
|
||||
pub mod lifecycle;
|
||||
pub mod sessions;
|
||||
pub mod ssm;
|
||||
pub mod tunnels;
|
||||
pub mod watch;
|
||||
|
||||
pub use crate::docker::exec::{
|
||||
@@ -150,11 +153,28 @@ impl ContainerTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The target for a project's container, if it has one yet.
|
||||
/// The target for a project's container, if it has one yet. An AWS
|
||||
/// project's is its registered tunnel's agent endpoint (`tunnels.rs`):
|
||||
/// none while no tunnel is open.
|
||||
pub fn for_project(project: &Project) -> Option<Self> {
|
||||
if project.is_aws() {
|
||||
return tunnels::get_endpoint(&project.id).map(ContainerTarget::Agent);
|
||||
}
|
||||
project.container_id.as_deref().map(Self::docker)
|
||||
}
|
||||
|
||||
/// [`Self::for_project`], waiting (up to `aws_host::REATTACH_WAIT`) for an
|
||||
/// AWS project whose tunnel is being re-opened in the background after a
|
||||
/// restart. For a Docker project it is `for_project`.
|
||||
pub async fn for_project_waiting(project: &Project) -> Option<Self> {
|
||||
if project.is_aws() {
|
||||
return tunnels::wait_endpoint(&project.id, aws_host::REATTACH_WAIT)
|
||||
.await
|
||||
.map(ContainerTarget::Agent);
|
||||
}
|
||||
Self::for_project(project)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -250,6 +270,23 @@ impl ContainerTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::exec_oneshot_limited`] for a background poll: on the agent it
|
||||
/// carries the background marker, so it never counts as activity for
|
||||
/// idle stop. Docker has no idle stop; the call is the same there.
|
||||
pub async fn exec_oneshot_background(
|
||||
&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_background(ep, user, cmd, limit).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// stdout and stderr kept apart: `(stdout, stderr, exit_code)`.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
&self,
|
||||
@@ -265,6 +302,22 @@ impl ContainerTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::exec_oneshot_streams_as`] for a background poll (the file
|
||||
/// viewer's change check): on the agent it carries the background marker,
|
||||
/// so it never counts as activity for idle stop. The same call on Docker.
|
||||
pub async fn exec_oneshot_streams_background(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_streams_as(container_id, user, cmd, Vec::new()).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_background(ep, user, cmd).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> {
|
||||
|
||||
@@ -0,0 +1,851 @@
|
||||
//! 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.
|
||||
///
|
||||
/// The AWS host builds its target from `aws::tasks::wait_ready`'s answer
|
||||
/// instead (`aws_host::ecs_task`); this stays for the live tests.
|
||||
#[allow(dead_code)]
|
||||
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.
|
||||
#[allow(dead_code)] // `EcsTask::resolve`
|
||||
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>,
|
||||
#[allow(dead_code)] // `plugin_pid()`, for diagnostics and the tests
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // diagnostics and the tests (Task 8 may show the state)
|
||||
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)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // diagnostics and the tests (Task 8 may show the state)
|
||||
pub fn state(&self) -> watch::Receiver<TunnelState> {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
/// The running plugin's process id, if one is running.
|
||||
#[allow(dead_code)] // diagnostics and the tests (Task 8 may show the state)
|
||||
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,480 @@
|
||||
//! The live SSM tunnels to AWS projects' agents, by project id.
|
||||
//!
|
||||
//! An AWS project's [`ContainerTarget`](super::ContainerTarget) is an agent
|
||||
//! endpoint at the loopback end of a [`PortForward`](super::ssm::PortForward);
|
||||
//! this registry is where `ContainerTarget::for_project` finds it, where
|
||||
//! `ProjectHost::stop` finds the task behind an endpoint, and what the
|
||||
//! shutdown path closes (every forward runs a `session-manager-plugin` child
|
||||
//! that would otherwise outlive the app — see `ssm.rs`).
|
||||
//!
|
||||
//! It also remembers which targets the AWS death watch found **stopped**, so
|
||||
//! `ContainerTarget::has_exited` can answer for an agent target (R3): only
|
||||
//! the host that runs the task can confirm a death, never the agent.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use futures_util::future::BoxFuture;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::agent::AgentEndpoint;
|
||||
use crate::aws::tasks::StopKind;
|
||||
use super::ssm::{EcsTask, PortForward};
|
||||
|
||||
/// Something that owns a tunnel's resources: the real [`PortForward`], or a
|
||||
/// test stand-in.
|
||||
pub trait TunnelHandle: Send + Sync {
|
||||
fn close(self: Box<Self>) -> BoxFuture<'static, ()>;
|
||||
}
|
||||
|
||||
impl TunnelHandle for PortForward {
|
||||
fn close(self: Box<Self>) -> BoxFuture<'static, ()> {
|
||||
Box::pin(async move { (*self).close().await })
|
||||
}
|
||||
}
|
||||
|
||||
/// One project's tunnel and the task it reaches.
|
||||
pub struct LiveTunnel {
|
||||
/// The SSM target (kept for diagnostics; the forward already uses it).
|
||||
#[allow(dead_code)]
|
||||
pub task: EcsTask,
|
||||
pub task_arn: String,
|
||||
pub cluster_arn: String,
|
||||
pub connection_id: String,
|
||||
pub region: String,
|
||||
/// The connection's SDK config, so a stop needs no settings lookup.
|
||||
pub sdk: SdkConfig,
|
||||
pub forward: Box<dyn TunnelHandle>,
|
||||
pub endpoint: AgentEndpoint,
|
||||
/// Runtime handles for the store (`set_aws_runtime`).
|
||||
pub access_point_id: Option<String>,
|
||||
pub task_definition_arn: String,
|
||||
}
|
||||
|
||||
/// Where an endpoint's task is: `(connection, region, cluster, task ARN)`.
|
||||
pub type TaskOf = (String, String, String, String);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
live: HashMap<String, LiveTunnel>,
|
||||
/// Target ids (endpoint base URLs) whose task the AWS watch saw STOPPED,
|
||||
/// and why.
|
||||
stopped: HashMap<String, StopKind>,
|
||||
/// Projects whose tunnel is being re-opened in the background.
|
||||
pending: HashSet<String>,
|
||||
/// Bumped by every [`close`] (a stop or remove), every [`register`] (a
|
||||
/// start's own tunnel) and [`cancel_reattach`]: a background re-attach
|
||||
/// that began under an older generation must never register.
|
||||
generation: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<Registry> {
|
||||
static R: std::sync::OnceLock<Mutex<Registry>> = std::sync::OnceLock::new();
|
||||
R.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Registry> {
|
||||
registry().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Wakes [`wait_endpoint`] callers when the registry changes.
|
||||
fn changed() -> &'static watch::Sender<u64> {
|
||||
static C: std::sync::OnceLock<watch::Sender<u64>> = std::sync::OnceLock::new();
|
||||
C.get_or_init(|| watch::channel(0).0)
|
||||
}
|
||||
|
||||
fn notify() {
|
||||
changed().send_modify(|n| *n = n.wrapping_add(1));
|
||||
}
|
||||
|
||||
/// The target id an AWS project has while no tunnel is registered for it
|
||||
/// (see `aws_host::unattached_container_id`): never a loopback URL, so a
|
||||
/// port a new tunnel reuses can't be confused with it.
|
||||
pub fn unattached_id(project_id: &str) -> String {
|
||||
format!("http://unattached.invalid/{project_id}/")
|
||||
}
|
||||
|
||||
/// Forget that the watch saw the task behind `target_id` stopped. For a
|
||||
/// project's [`unattached_id`], which (unlike an endpoint) is reused by
|
||||
/// every later unattached spell of the same project: a stale entry would
|
||||
/// make a later live task read as stopped.
|
||||
pub fn forget_stopped(target_id: &str) {
|
||||
lock().stopped.remove(target_id);
|
||||
}
|
||||
|
||||
/// Register `tunnel` for `project_id`, returning the one it replaces (the
|
||||
/// caller closes it).
|
||||
pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option<LiveTunnel> {
|
||||
let old = {
|
||||
let mut r = lock();
|
||||
*r.generation.entry(project_id.to_string()).or_default() += 1;
|
||||
r.stopped.remove(tunnel.endpoint.base_url.as_str());
|
||||
r.stopped.remove(&unattached_id(project_id));
|
||||
r.pending.remove(project_id);
|
||||
r.live.insert(project_id.to_string(), tunnel)
|
||||
};
|
||||
notify();
|
||||
old
|
||||
}
|
||||
|
||||
pub fn get_endpoint(project_id: &str) -> Option<AgentEndpoint> {
|
||||
lock().live.get(project_id).map(|t| t.endpoint.clone())
|
||||
}
|
||||
|
||||
/// `(task ARN, access point id, task definition ARN)` of the project's tunnel.
|
||||
pub fn runtime(project_id: &str) -> Option<(String, Option<String>, String)> {
|
||||
lock().live.get(project_id).map(|t| {
|
||||
(
|
||||
t.task_arn.clone(),
|
||||
t.access_point_id.clone(),
|
||||
t.task_definition_arn.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The project whose tunnel is `ep` and its task.
|
||||
pub fn task_of(ep: &AgentEndpoint) -> Option<TaskOf> {
|
||||
lock().live.values().find(|t| t.endpoint.base_url == ep.base_url).map(|t| {
|
||||
(
|
||||
t.connection_id.clone(),
|
||||
t.region.clone(),
|
||||
t.cluster_arn.clone(),
|
||||
t.task_arn.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The project id and SDK config behind `ep`.
|
||||
pub(crate) fn owner_of(ep: &AgentEndpoint) -> Option<(String, SdkConfig)> {
|
||||
lock()
|
||||
.live
|
||||
.iter()
|
||||
.find(|(_, t)| t.endpoint.base_url == ep.base_url)
|
||||
.map(|(id, t)| (id.clone(), t.sdk.clone()))
|
||||
}
|
||||
|
||||
/// A background re-attach of `project_id` begins: marks it pending and
|
||||
/// returns the generation [`register_if_current`] must still see.
|
||||
pub fn begin_reattach(project_id: &str) -> u64 {
|
||||
let g = {
|
||||
let mut r = lock();
|
||||
r.pending.insert(project_id.to_string());
|
||||
*r.generation.entry(project_id.to_string()).or_default()
|
||||
};
|
||||
notify();
|
||||
g
|
||||
}
|
||||
|
||||
/// [`register`], unless the project was closed (stopped, removed) since
|
||||
/// `generation` was taken: then the tunnel is handed back for the caller to
|
||||
/// close, and nothing is registered.
|
||||
pub fn register_if_current(
|
||||
project_id: &str,
|
||||
generation: u64,
|
||||
tunnel: LiveTunnel,
|
||||
) -> Result<Option<LiveTunnel>, Box<LiveTunnel>> {
|
||||
let old = {
|
||||
let mut r = lock();
|
||||
if r.generation.get(project_id).copied().unwrap_or_default() != generation {
|
||||
return Err(Box::new(tunnel));
|
||||
}
|
||||
r.stopped.remove(tunnel.endpoint.base_url.as_str());
|
||||
r.stopped.remove(&unattached_id(project_id));
|
||||
r.pending.remove(project_id);
|
||||
r.live.insert(project_id.to_string(), tunnel)
|
||||
};
|
||||
notify();
|
||||
Ok(old)
|
||||
}
|
||||
|
||||
/// [`close`], but only when the registered tunnel leads to `task_arn` (a
|
||||
/// death report about an older task must not close a newer one's tunnel).
|
||||
pub async fn close_if_task(project_id: &str, task_arn: &str) {
|
||||
let taken = {
|
||||
let mut r = lock();
|
||||
if !r.live.get(project_id).is_some_and(|t| t.task_arn == task_arn) {
|
||||
return;
|
||||
}
|
||||
take(&mut r, project_id)
|
||||
};
|
||||
notify();
|
||||
if let Some(t) = taken {
|
||||
t.forward.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Under the lock: invalidate a re-attach in flight and take the tunnel out.
|
||||
fn take(r: &mut Registry, project_id: &str) -> Option<LiveTunnel> {
|
||||
r.pending.remove(project_id);
|
||||
*r.generation.entry(project_id.to_string()).or_default() += 1;
|
||||
let t = r.live.remove(project_id);
|
||||
if let Some(ref t) = t {
|
||||
r.stopped.remove(t.endpoint.base_url.as_str());
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// A start is about to attach itself: any background re-attach in flight
|
||||
/// is cancelled (it will hand its tunnel back and close it).
|
||||
pub fn cancel_reattach(project_id: &str) {
|
||||
{
|
||||
let mut r = lock();
|
||||
r.pending.remove(project_id);
|
||||
*r.generation.entry(project_id.to_string()).or_default() += 1;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/// A background re-attach that began at `generation` gave up: clear the
|
||||
/// pending flag, unless something newer has taken over since.
|
||||
pub fn end_reattach(project_id: &str, generation: u64) {
|
||||
{
|
||||
let mut r = lock();
|
||||
if r.generation.get(project_id).copied().unwrap_or_default() == generation {
|
||||
r.pending.remove(project_id);
|
||||
}
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/// Take the project's tunnel out and close it, and invalidate a background
|
||||
/// re-attach in flight.
|
||||
pub async fn close(project_id: &str) {
|
||||
let taken = take(&mut lock(), project_id);
|
||||
notify();
|
||||
if let Some(t) = taken {
|
||||
t.forward.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Close every tunnel, concurrently. The shutdown path's step.
|
||||
pub async fn close_all() {
|
||||
let all: Vec<LiveTunnel> = {
|
||||
let mut r = lock();
|
||||
r.pending.clear();
|
||||
r.stopped.clear();
|
||||
for g in r.generation.values_mut() {
|
||||
*g += 1;
|
||||
}
|
||||
r.live.drain().map(|(_, t)| t).collect()
|
||||
};
|
||||
notify();
|
||||
futures_util::future::join_all(all.into_iter().map(|t| t.forward.close())).await;
|
||||
}
|
||||
|
||||
/// The AWS watch saw the task behind target `target_id` STOPPED, for `why`.
|
||||
pub fn mark_stopped(target_id: &str, why: StopKind) {
|
||||
lock().stopped.insert(target_id.to_string(), why);
|
||||
}
|
||||
|
||||
/// Whether the AWS watch has confirmed the task behind `target_id` stopped.
|
||||
pub fn is_stopped(target_id: &str) -> bool {
|
||||
lock().stopped.contains_key(target_id)
|
||||
}
|
||||
|
||||
/// Why the task behind `target_id` stopped, once the watch has seen it.
|
||||
pub fn stop_kind(target_id: &str) -> Option<StopKind> {
|
||||
lock().stopped.get(target_id).cloned()
|
||||
}
|
||||
|
||||
pub fn is_pending(project_id: &str) -> bool {
|
||||
lock().pending.contains(project_id)
|
||||
}
|
||||
|
||||
/// The project's endpoint, waiting up to `timeout` while a background
|
||||
/// re-attach is still opening it. `None` when there is none.
|
||||
pub async fn wait_endpoint(project_id: &str, timeout: Duration) -> Option<AgentEndpoint> {
|
||||
let mut rx = changed().subscribe();
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(ep) = get_endpoint(project_id) {
|
||||
return Some(ep);
|
||||
}
|
||||
if !is_pending(project_id) {
|
||||
return None;
|
||||
}
|
||||
if tokio::time::timeout_at(deadline, rx.changed()).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stand-in tunnel that counts its closes.
|
||||
pub(crate) struct FakeTunnel(pub Arc<AtomicUsize>);
|
||||
impl TunnelHandle for FakeTunnel {
|
||||
fn close(self: Box<Self>) -> BoxFuture<'static, ()> {
|
||||
let n = self.0.clone();
|
||||
Box::pin(async move {
|
||||
n.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fake_tunnel(port: u16, closes: Arc<AtomicUsize>) -> LiveTunnel {
|
||||
LiveTunnel {
|
||||
task: EcsTask {
|
||||
cluster: "triple-c-dev".into(),
|
||||
task_id: format!("t{port}"),
|
||||
runtime_id: "r".into(),
|
||||
},
|
||||
task_arn: format!("arn:aws:ecs:eu-west-1:111122223333:task/triple-c-dev/t{port}"),
|
||||
cluster_arn: "arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-dev".into(),
|
||||
connection_id: "c1".into(),
|
||||
region: "eu-west-1".into(),
|
||||
sdk: SdkConfig::builder().build(),
|
||||
forward: Box::new(FakeTunnel(closes)),
|
||||
endpoint: AgentEndpoint::loopback(port, "tok"),
|
||||
access_point_id: Some("fsap-1".into()),
|
||||
task_definition_arn: "arn:td:1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pid(tag: &str) -> String {
|
||||
format!("tunnels-test-{tag}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_registered_tunnel_is_found_by_project_and_by_endpoint_and_closed_once() {
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
let p = pid("reg");
|
||||
assert!(get_endpoint(&p).is_none());
|
||||
assert!(register(&p, fake_tunnel(61001, closes.clone())).is_none());
|
||||
let ep = get_endpoint(&p).unwrap();
|
||||
assert_eq!(ep, AgentEndpoint::loopback(61001, "tok"));
|
||||
let (conn, region, _, arn) = task_of(&ep).unwrap();
|
||||
assert_eq!((conn.as_str(), region.as_str()), ("c1", "eu-west-1"));
|
||||
assert!(arn.ends_with("/t61001"));
|
||||
assert_eq!(runtime(&p).unwrap().1.as_deref(), Some("fsap-1"));
|
||||
close(&p).await;
|
||||
close(&p).await;
|
||||
assert_eq!(closes.load(Ordering::SeqCst), 1);
|
||||
assert!(get_endpoint(&p).is_none() && task_of(&ep).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_registering_hands_back_the_old_tunnel_and_clears_a_stale_stop() {
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
let p = pid("rereg");
|
||||
register(&p, fake_tunnel(61002, closes.clone()));
|
||||
let id = AgentEndpoint::loopback(61003, "tok").base_url.to_string();
|
||||
mark_stopped(&id, StopKind::Spot);
|
||||
assert_eq!(stop_kind(&id), Some(StopKind::Spot));
|
||||
let old = register(&p, fake_tunnel(61003, closes.clone())).unwrap();
|
||||
assert_eq!(old.endpoint.base_url.port(), Some(61002));
|
||||
assert!(!is_stopped(&id), "a new task on a reused port is not stopped");
|
||||
old.forward.close().await;
|
||||
close(&p).await;
|
||||
assert_eq!(closes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_registering_tunnel_clears_a_stop_seen_under_the_projects_unattached_id() {
|
||||
let p = pid("unattached-stale");
|
||||
let placeholder = unattached_id(&p);
|
||||
mark_stopped(&placeholder, StopKind::Spot);
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
register(&p, fake_tunnel(61301, closes.clone()));
|
||||
assert!(!is_stopped(&placeholder), "a later unattached spell must not read as stopped");
|
||||
close(&p).await;
|
||||
|
||||
// A re-attach registering clears it too; and it can be forgotten directly.
|
||||
let q = pid("unattached-stale-reattach");
|
||||
mark_stopped(&unattached_id(&q), StopKind::Spot);
|
||||
let g = begin_reattach(&q);
|
||||
assert!(register_if_current(&q, g, fake_tunnel(61302, closes.clone())).is_ok());
|
||||
assert!(!is_stopped(&unattached_id(&q)));
|
||||
close(&q).await;
|
||||
mark_stopped(&unattached_id(&q), StopKind::Idle);
|
||||
forget_stopped(&unattached_id(&q));
|
||||
assert!(!is_stopped(&unattached_id(&q)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reattach_that_began_before_a_stop_does_not_register() {
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
let p = pid("gen");
|
||||
let g = begin_reattach(&p);
|
||||
assert!(is_pending(&p));
|
||||
close(&p).await; // the user stopped the project meanwhile
|
||||
let back = register_if_current(&p, g, fake_tunnel(61005, closes.clone()));
|
||||
let t = back.err().expect("handed back, not registered");
|
||||
assert!(get_endpoint(&p).is_none() && !is_pending(&p));
|
||||
t.forward.close().await;
|
||||
assert_eq!(closes.load(Ordering::SeqCst), 1);
|
||||
|
||||
let g = begin_reattach(&p);
|
||||
assert!(register_if_current(&p, g, fake_tunnel(61006, closes.clone())).is_ok());
|
||||
assert!(get_endpoint(&p).is_some());
|
||||
close(&p).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_starts_tunnel_survives_a_reattach_that_finishes_after_it() {
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
// Start cancels the pending re-attach, then registers its own.
|
||||
let p = pid("start-wins");
|
||||
let g = begin_reattach(&p);
|
||||
cancel_reattach(&p);
|
||||
assert!(!is_pending(&p));
|
||||
register(&p, fake_tunnel(61010, closes.clone()));
|
||||
let late = register_if_current(&p, g, fake_tunnel(61011, closes.clone()));
|
||||
late.err().expect("the late re-attach is refused").forward.close().await;
|
||||
assert_eq!(get_endpoint(&p).unwrap().base_url.port(), Some(61010), "Start's tunnel stays");
|
||||
assert_eq!(closes.load(Ordering::SeqCst), 1, "only the late tunnel was closed");
|
||||
// A stale end of that re-attach does not clear a newer one's pending flag.
|
||||
let g2 = begin_reattach(&p);
|
||||
end_reattach(&p, g);
|
||||
assert!(is_pending(&p));
|
||||
end_reattach(&p, g2);
|
||||
assert!(!is_pending(&p));
|
||||
close(&p).await;
|
||||
|
||||
// Even without a cancel, a plain register makes an older re-attach stale.
|
||||
let p = pid("register-bumps");
|
||||
let g = begin_reattach(&p);
|
||||
register(&p, fake_tunnel(61012, closes.clone()));
|
||||
assert!(register_if_current(&p, g, fake_tunnel(61013, closes.clone())).is_err());
|
||||
assert_eq!(get_endpoint(&p).unwrap().base_url.port(), Some(61012));
|
||||
close(&p).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_death_report_closes_only_the_tunnel_of_the_task_that_died() {
|
||||
let closes = Arc::new(AtomicUsize::new(0));
|
||||
let p = pid("died");
|
||||
register(&p, fake_tunnel(61007, closes.clone()));
|
||||
close_if_task(&p, "arn:aws:ecs:eu-west-1:111122223333:task/triple-c-dev/older").await;
|
||||
assert!(get_endpoint(&p).is_some(), "a newer task's tunnel stays");
|
||||
close_if_task(&p, "arn:aws:ecs:eu-west-1:111122223333:task/triple-c-dev/t61007").await;
|
||||
assert!(get_endpoint(&p).is_none());
|
||||
assert_eq!(closes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_for_an_endpoint_ends_when_it_is_registered_or_the_reattach_gives_up() {
|
||||
let p = pid("wait");
|
||||
assert!(wait_endpoint(&p, Duration::from_secs(5)).await.is_none(), "nothing pending");
|
||||
begin_reattach(&p);
|
||||
let p2 = p.clone();
|
||||
let waiter = tokio::spawn(async move { wait_endpoint(&p2, Duration::from_secs(5)).await });
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
register(&p, fake_tunnel(61004, Arc::new(AtomicUsize::new(0))));
|
||||
assert_eq!(waiter.await.unwrap().unwrap().base_url.port(), Some(61004));
|
||||
close(&p).await;
|
||||
|
||||
let g = begin_reattach(&p);
|
||||
let p2 = p.clone();
|
||||
let waiter = tokio::spawn(async move { wait_endpoint(&p2, Duration::from_secs(5)).await });
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
end_reattach(&p, g);
|
||||
assert!(waiter.await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,140 @@
|
||||
//! 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.
|
||||
//! Docker pushes `die` events. AWS has no push stream from the app's point of
|
||||
//! view; its [`ExitSource::deaths`] is a `DescribeTasks` poll (every 15 s) over
|
||||
//! the tasks of Running AWS projects that yields the stopped ones, which
|
||||
//! `container_watch` handles the same way because every report is confirmed
|
||||
//! with [`ContainerTarget::has_exited`] anyway.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::future::BoxFuture;
|
||||
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)]
|
||||
/// How often the AWS watch asks ECS (spec, "Lifecycle and status").
|
||||
pub const AWS_POLL_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Which host's deaths to watch.
|
||||
#[derive(Clone)]
|
||||
pub enum ExitSource {
|
||||
Docker,
|
||||
Aws(AwsWatch),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ExitSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ExitSource::Docker => f.write_str("Docker"),
|
||||
ExitSource::Aws(w) => write!(f, "Aws(every {:?})", w.interval),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a task's DescribeTasks goes: the connection and region its
|
||||
/// credentials and endpoint come from, and the cluster.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct TaskGroup {
|
||||
pub connection_id: String,
|
||||
pub region: String,
|
||||
pub cluster_arn: String,
|
||||
}
|
||||
|
||||
/// A Running AWS project's task, and the target a death is reported as.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WatchedTask {
|
||||
pub target: ContainerTarget,
|
||||
pub group: TaskGroup,
|
||||
pub task_arn: String,
|
||||
}
|
||||
|
||||
/// The project-to-task lookup the AWS watch runs on, injected so it can be
|
||||
/// faked (Task 6 backs it with the tunnel registry / project store and
|
||||
/// `aws::tasks::stopped_among`).
|
||||
pub trait AwsTaskLookup: Send + Sync {
|
||||
/// The tasks of the projects that are Running now. Read on every poll.
|
||||
fn running_tasks(&self) -> Vec<WatchedTask>;
|
||||
/// Of `arns` (all in `group`, at most 100), the ones that are STOPPED or
|
||||
/// gone. `Err` when ECS could not be asked.
|
||||
fn stopped<'a>(
|
||||
&'a self,
|
||||
group: &'a TaskGroup,
|
||||
arns: &'a [String],
|
||||
) -> BoxFuture<'a, Result<Vec<String>, String>>;
|
||||
}
|
||||
|
||||
/// The AWS death poll: a lookup and how often to run it.
|
||||
#[derive(Clone)]
|
||||
pub struct AwsWatch {
|
||||
lookup: Arc<dyn AwsTaskLookup>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
impl AwsWatch {
|
||||
pub fn new(lookup: Arc<dyn AwsTaskLookup>) -> Self {
|
||||
Self::every(lookup, AWS_POLL_INTERVAL)
|
||||
}
|
||||
pub fn every(lookup: Arc<dyn AwsTaskLookup>, interval: Duration) -> Self {
|
||||
AwsWatch { lookup, interval }
|
||||
}
|
||||
}
|
||||
|
||||
/// DescribeTasks' batch limit.
|
||||
const DESCRIBE_BATCH: usize = 100;
|
||||
|
||||
/// One poll: the targets of Running projects whose task stopped. Tasks are
|
||||
/// asked about per group (connection + region + cluster), 100 at a time. A
|
||||
/// group that can't be asked (expired SSO, network) is logged and skipped —
|
||||
/// it confirms nothing, and the other groups are still watched; the next
|
||||
/// poll asks again.
|
||||
pub async fn poll_stopped(lookup: &dyn AwsTaskLookup) -> Vec<ContainerTarget> {
|
||||
let mut groups: HashMap<TaskGroup, Vec<WatchedTask>> = HashMap::new();
|
||||
for t in lookup.running_tasks() {
|
||||
groups.entry(t.group.clone()).or_default().push(t);
|
||||
}
|
||||
let mut dead = Vec::new();
|
||||
for (group, tasks) in groups {
|
||||
for chunk in tasks.chunks(DESCRIBE_BATCH) {
|
||||
let arns: Vec<String> = chunk.iter().map(|t| t.task_arn.clone()).collect();
|
||||
match lookup.stopped(&group, &arns).await {
|
||||
Ok(stopped) => dead.extend(
|
||||
chunk
|
||||
.iter()
|
||||
.filter(|t| stopped.contains(&t.task_arn))
|
||||
.map(|t| t.target.clone()),
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"Container watch: could not check AWS tasks in {} ({}): {e}",
|
||||
group.region,
|
||||
group.cluster_arn
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
dead
|
||||
}
|
||||
|
||||
/// Polls forever, `interval` apart (the first after one interval: the
|
||||
/// watcher sweeps on connect), yielding each stopped task's target.
|
||||
fn aws_deaths(watch: AwsWatch) -> DeathStream {
|
||||
Box::pin(futures_util::stream::unfold(
|
||||
(watch, VecDeque::new()),
|
||||
|(watch, mut queue)| async move {
|
||||
loop {
|
||||
if let Some(t) = queue.pop_front() {
|
||||
return Some((Ok(t), (watch, queue)));
|
||||
}
|
||||
tokio::time::sleep(watch.interval).await;
|
||||
queue.extend(poll_stopped(&*watch.lookup).await);
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub type DeathStream = Pin<Box<dyn Stream<Item = Result<ContainerTarget, String>> + Send>>;
|
||||
@@ -28,6 +147,9 @@ impl ExitSource {
|
||||
pub async fn reachable(&self) -> bool {
|
||||
match self {
|
||||
ExitSource::Docker => docker::events::ping().await,
|
||||
// No one endpoint to ping: each poll asks every group, and a
|
||||
// group that fails is skipped rather than ending the stream.
|
||||
ExitSource::Aws(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +160,9 @@ impl ExitSource {
|
||||
ExitSource::Docker => Box::pin(
|
||||
docker::events::container_deaths(since).map(|r| r.map(ContainerTarget::docker)),
|
||||
),
|
||||
// ECS has no replay: a task that stopped while the stream was
|
||||
// down is still STOPPED (or gone) on the next poll.
|
||||
ExitSource::Aws(watch) => aws_deaths(watch.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,9 +177,158 @@ impl ContainerTarget {
|
||||
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,
|
||||
// runs the container can confirm a death: the AWS watch records
|
||||
// the targets whose task ECS reports STOPPED in the tunnel
|
||||
// registry (R3), and nothing else ever confirms one.
|
||||
ContainerTarget::Agent(ep) => super::tunnels::is_stopped(ep.base_url.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn group(conn: &str, region: &str) -> TaskGroup {
|
||||
TaskGroup {
|
||||
connection_id: conn.into(),
|
||||
region: region.into(),
|
||||
cluster_arn: format!("arn:aws:ecs:{region}:111122223333:cluster/triple-c-dev"),
|
||||
}
|
||||
}
|
||||
|
||||
fn task(n: usize, g: &TaskGroup) -> WatchedTask {
|
||||
WatchedTask {
|
||||
target: ContainerTarget::docker(format!("target-{:012}", n)),
|
||||
group: g.clone(),
|
||||
task_arn: format!("arn:aws:ecs:{}:111122223333:task/triple-c-dev/t{n}", g.region),
|
||||
}
|
||||
}
|
||||
|
||||
/// Running tasks are fixed; `dead` are the stopped ARNs; a group whose
|
||||
/// region is `failing` errors. Every `stopped` call is recorded.
|
||||
struct FakeLookup {
|
||||
tasks: Mutex<Vec<WatchedTask>>,
|
||||
dead: Vec<String>,
|
||||
failing: &'static str,
|
||||
asked: Mutex<Vec<(TaskGroup, usize)>>,
|
||||
polls: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl FakeLookup {
|
||||
fn new(tasks: Vec<WatchedTask>, dead: Vec<String>) -> Self {
|
||||
FakeLookup {
|
||||
tasks: Mutex::new(tasks),
|
||||
dead,
|
||||
failing: "",
|
||||
asked: Mutex::new(Vec::new()),
|
||||
polls: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AwsTaskLookup for FakeLookup {
|
||||
fn running_tasks(&self) -> Vec<WatchedTask> {
|
||||
*self.polls.lock().unwrap() += 1;
|
||||
self.tasks.lock().unwrap().clone()
|
||||
}
|
||||
fn stopped<'a>(
|
||||
&'a self,
|
||||
group: &'a TaskGroup,
|
||||
arns: &'a [String],
|
||||
) -> BoxFuture<'a, Result<Vec<String>, String>> {
|
||||
Box::pin(async move {
|
||||
self.asked.lock().unwrap().push((group.clone(), arns.len()));
|
||||
if group.region == self.failing {
|
||||
return Err("SSO session expired".into());
|
||||
}
|
||||
Ok(arns.iter().filter(|a| self.dead.contains(a)).cloned().collect())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_poll_asks_each_group_once_and_reports_only_stopped_tasks() {
|
||||
let (a, b) = (group("c1", "eu-west-1"), group("c2", "us-east-1"));
|
||||
let tasks = vec![task(1, &a), task(2, &b), task(3, &a)];
|
||||
let dead = vec![tasks[0].task_arn.clone(), tasks[1].task_arn.clone()];
|
||||
let lookup = FakeLookup::new(tasks.clone(), dead);
|
||||
let mut got = poll_stopped(&lookup).await;
|
||||
got.sort_by(|x, y| x.id().cmp(y.id()));
|
||||
assert_eq!(got, vec![tasks[0].target.clone(), tasks[1].target.clone()]);
|
||||
let mut asked = lookup.asked.lock().unwrap().clone();
|
||||
asked.sort_by(|x, y| x.0.region.cmp(&y.0.region));
|
||||
assert_eq!(asked, vec![(a, 2), (b, 1)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_group_that_cannot_be_asked_confirms_nothing_and_the_others_still_report() {
|
||||
let (a, b) = (group("c1", "eu-west-1"), group("c2", "us-east-1"));
|
||||
let tasks = vec![task(1, &a), task(2, &b)];
|
||||
let dead = tasks.iter().map(|t| t.task_arn.clone()).collect();
|
||||
let mut lookup = FakeLookup::new(tasks.clone(), dead);
|
||||
lookup.failing = "eu-west-1";
|
||||
assert_eq!(poll_stopped(&lookup).await, vec![tasks[1].target.clone()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tasks_are_described_a_hundred_at_a_time() {
|
||||
let a = group("c1", "eu-west-1");
|
||||
let lookup = FakeLookup::new((0..250).map(|n| task(n, &a)).collect(), Vec::new());
|
||||
assert!(poll_stopped(&lookup).await.is_empty());
|
||||
let sizes: Vec<usize> = lookup.asked.lock().unwrap().iter().map(|(_, n)| *n).collect();
|
||||
assert_eq!(sizes, vec![100, 100, 50]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_running_aws_projects_means_no_aws_calls() {
|
||||
let lookup = FakeLookup::new(Vec::new(), Vec::new());
|
||||
assert!(poll_stopped(&lookup).await.is_empty());
|
||||
assert!(lookup.asked.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn the_aws_stream_polls_every_interval_and_yields_each_death() {
|
||||
let a = group("c1", "eu-west-1");
|
||||
let tasks = vec![task(1, &a), task(2, &a)];
|
||||
let lookup = Arc::new(FakeLookup::new(
|
||||
tasks.clone(),
|
||||
vec![tasks[0].task_arn.clone(), tasks[1].task_arn.clone()],
|
||||
));
|
||||
let source = ExitSource::Aws(AwsWatch::new(lookup.clone()));
|
||||
assert!(source.reachable().await);
|
||||
let mut deaths = source.deaths(0);
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let first = deaths.next().await.unwrap().unwrap();
|
||||
assert_eq!(t0.elapsed(), AWS_POLL_INTERVAL, "the first poll waits one interval");
|
||||
let second = deaths.next().await.unwrap().unwrap();
|
||||
assert_eq!(t0.elapsed(), AWS_POLL_INTERVAL, "one poll, both reported");
|
||||
let mut got = vec![first, second];
|
||||
got.sort_by(|x, y| x.id().cmp(y.id()));
|
||||
assert_eq!(got, vec![tasks[0].target.clone(), tasks[1].target.clone()]);
|
||||
assert_eq!(*lookup.polls.lock().unwrap(), 1);
|
||||
|
||||
// Once the projects are no longer Running, polls go on and report nothing.
|
||||
lookup.tasks.lock().unwrap().clear();
|
||||
let quiet = tokio::time::timeout(AWS_POLL_INTERVAL * 3 + Duration::from_secs(1), deaths.next()).await;
|
||||
assert!(quiet.is_err(), "nothing to report");
|
||||
assert_eq!(*lookup.polls.lock().unwrap(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_aws_poll_runs_every_fifteen_seconds() {
|
||||
assert_eq!(AWS_POLL_INTERVAL, Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_target_never_confirms_an_exit_on_its_own() {
|
||||
// R3: Task 6 wires the tunnel registry into this arm.
|
||||
let ep = crate::host::agent::AgentEndpoint::new(
|
||||
url::Url::parse("http://127.0.0.1:47901").unwrap(),
|
||||
"fake-token",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!ContainerTarget::Agent(ep).has_exited().await);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod auth_bridge;
|
||||
mod aws;
|
||||
mod browser_view;
|
||||
#[cfg(test)]
|
||||
mod command_census;
|
||||
@@ -210,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();
|
||||
|
||||
@@ -321,6 +329,20 @@ pub fn run() {
|
||||
lifecycle_setup.track(container_watch::spawn(
|
||||
app.handle().clone(),
|
||||
projects_store_setup.clone(),
|
||||
crate::host::watch::ExitSource::Docker,
|
||||
lifecycle_setup.cancellation(),
|
||||
));
|
||||
// The same for AWS projects: a DescribeTasks poll every 15 s over
|
||||
// the tasks of Running AWS projects (none: no AWS calls at all).
|
||||
lifecycle_setup.track(container_watch::spawn(
|
||||
app.handle().clone(),
|
||||
projects_store_setup.clone(),
|
||||
crate::host::watch::ExitSource::Aws(crate::host::watch::AwsWatch::new(
|
||||
std::sync::Arc::new(crate::host::aws_host::StoreTaskLookup {
|
||||
projects: projects_store_setup.clone(),
|
||||
settings: settings_store_setup.clone(),
|
||||
}),
|
||||
)),
|
||||
lifecycle_setup.cancellation(),
|
||||
));
|
||||
|
||||
@@ -500,6 +522,9 @@ pub fn run() {
|
||||
exec_manager.close_all_sessions(),
|
||||
auth_bridge.stop_all(),
|
||||
browser_view::manager().stop_all(),
|
||||
// Each AWS tunnel is a session-manager-plugin
|
||||
// child that `exit` would not end.
|
||||
crate::host::tunnels::close_all(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -526,8 +551,12 @@ pub fn run() {
|
||||
commands::project_commands::update_project,
|
||||
commands::project_commands::start_project_container,
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::check_unsaved_work,
|
||||
commands::project_commands::aws_task_log,
|
||||
commands::project_commands::remove_aws_resources,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
commands::project_commands::reconcile_aws_projects,
|
||||
// Notes
|
||||
commands::notes_commands::list_notes,
|
||||
commands::notes_commands::save_note,
|
||||
@@ -593,6 +622,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -452,10 +452,295 @@ pub struct Project {
|
||||
/// Global marketplace installs this project opts out of.
|
||||
#[serde(default)]
|
||||
pub marketplace_disabled: Vec<super::marketplace::MarketplaceItemRef>,
|
||||
/// The host connection this project runs on. `None` is the local Docker
|
||||
/// host; `Some(id)` names an entry of `AppSettings.hosts`.
|
||||
#[serde(default)]
|
||||
pub host_id: Option<String>,
|
||||
/// Region and environment of an AWS-hosted project. Set together with
|
||||
/// `host_id` and `aws`, or not at all.
|
||||
#[serde(default)]
|
||||
pub aws_placement: Option<AwsPlacement>,
|
||||
#[serde(default)]
|
||||
pub aws: Option<AwsWorkspaceConfig>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AwsPlacement {
|
||||
pub region: String,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Arch {
|
||||
#[default]
|
||||
X86_64,
|
||||
Arm64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StorageMode {
|
||||
Ephemeral,
|
||||
#[default]
|
||||
Hybrid,
|
||||
}
|
||||
|
||||
/// One git repository cloned into the workspace. `branch: None` means the
|
||||
/// remote's default branch.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RepoSource {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub branch: Option<String>,
|
||||
pub folder: String,
|
||||
}
|
||||
|
||||
fn default_aws_cpu() -> u32 {
|
||||
1024
|
||||
}
|
||||
fn default_aws_memory_mib() -> u32 {
|
||||
4096
|
||||
}
|
||||
fn default_aws_ephemeral_gib() -> u32 {
|
||||
21
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AwsWorkspaceConfig {
|
||||
#[serde(default = "default_aws_cpu")]
|
||||
pub cpu: u32,
|
||||
#[serde(default = "default_aws_memory_mib")]
|
||||
pub memory_mib: u32,
|
||||
#[serde(default)]
|
||||
pub arch: Arch,
|
||||
#[serde(default)]
|
||||
pub storage: StorageMode,
|
||||
#[serde(default = "default_aws_ephemeral_gib")]
|
||||
pub ephemeral_gib: u32,
|
||||
#[serde(default)]
|
||||
pub spot: bool,
|
||||
#[serde(default)]
|
||||
pub idle_stop_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub repos: Vec<RepoSource>,
|
||||
/// Push uncommitted work to a WIP branch when the task stops. Defaults on.
|
||||
#[serde(default = "default_full_permissions")]
|
||||
pub wip_push_on_stop: bool,
|
||||
/// Store-owned runtime state: see `ProjectsStore::set_aws_runtime`.
|
||||
#[serde(default)]
|
||||
pub task_arn: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access_point_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub task_definition_arn: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AwsWorkspaceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpu: default_aws_cpu(),
|
||||
memory_mib: default_aws_memory_mib(),
|
||||
arch: Arch::default(),
|
||||
storage: StorageMode::default(),
|
||||
ephemeral_gib: default_aws_ephemeral_gib(),
|
||||
spot: false,
|
||||
idle_stop_minutes: None,
|
||||
repos: Vec::new(),
|
||||
wip_push_on_stop: true,
|
||||
task_arn: None,
|
||||
access_point_id: None,
|
||||
task_definition_arn: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The valid Fargate memory sizes (MiB) for a CPU size, smallest first.
|
||||
/// 256 CPU is the irregular one: 512, 1024 or 2048 only.
|
||||
fn fargate_memory_sizes(cpu: u32) -> Option<Vec<u32>> {
|
||||
let (min, max, step) = match cpu {
|
||||
256 => return Some(vec![512, 1024, 2048]),
|
||||
512 => (1024, 4096, 1024),
|
||||
1024 => (2048, 8192, 1024),
|
||||
2048 => (4096, 16384, 1024),
|
||||
4096 => (8192, 30720, 1024),
|
||||
8192 => (16384, 61440, 4096),
|
||||
16384 => (32768, 122880, 8192),
|
||||
_ => return None,
|
||||
};
|
||||
Some((min..=max).step_by(step as usize).collect())
|
||||
}
|
||||
|
||||
fn validate_repo_url(url: &str) -> Result<(), String> {
|
||||
let bad = |m: &str| Err(format!("Repository URL \"{}\": {}", url.escape_debug(), m));
|
||||
if url.is_empty() || url.len() > 2048 {
|
||||
return bad("must be 1 to 2048 characters");
|
||||
}
|
||||
if url.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
||||
return bad("must not contain whitespace or control characters");
|
||||
}
|
||||
if url.starts_with('-') {
|
||||
return bad("must not start with '-'");
|
||||
}
|
||||
let ok = if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Credentials in the URL would sit in the project secret and in
|
||||
// `.git/config` in the clear, and leak into every clone's remote.
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
|
||||
if authority.contains('@') {
|
||||
return bad(
|
||||
"must not carry credentials (user:password@ or token@); put the token in the \
|
||||
project's Git HTTPS token setting (Access) instead",
|
||||
);
|
||||
}
|
||||
!rest.is_empty()
|
||||
} else if let Some(rest) = url.strip_prefix("ssh://") {
|
||||
!rest.is_empty()
|
||||
} else if let Some((host, path)) = url.split_once(':') {
|
||||
// scp-like `git@host:path`
|
||||
!host.is_empty() && !host.contains('/') && !path.is_empty() && host.contains('@')
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
bad("use https://..., ssh://... or git@host:path")
|
||||
}
|
||||
}
|
||||
|
||||
/// `git check-ref-format --branch` rules.
|
||||
fn validate_branch(branch: &str) -> Result<(), String> {
|
||||
let bad = |m: &str| Err(format!("Branch \"{}\": {}", branch.escape_debug(), m));
|
||||
if branch.is_empty() || branch.len() > 255 {
|
||||
return bad("must be 1 to 255 characters");
|
||||
}
|
||||
if branch.starts_with('-') || branch.starts_with('/') || branch.ends_with('/') {
|
||||
return bad("must not start with '-' or start/end with '/'");
|
||||
}
|
||||
// `HEAD` passes check-ref-format but `git clone --branch HEAD` fails
|
||||
// (and a branch named HEAD is refused by `git branch`).
|
||||
if branch == "HEAD" {
|
||||
return bad("HEAD is not a branch; leave the branch empty for the default branch");
|
||||
}
|
||||
if branch == "@" || branch.ends_with('.') || branch.contains("..") || branch.contains("@{") || branch.contains("//") {
|
||||
return bad("is not a valid branch name");
|
||||
}
|
||||
if branch
|
||||
.chars()
|
||||
.any(|c| c.is_control() || c.is_whitespace() || "~^:?*[\\".contains(c))
|
||||
{
|
||||
return bad("contains a character git does not allow");
|
||||
}
|
||||
for comp in branch.split('/') {
|
||||
if comp.starts_with('.') || comp.ends_with(".lock") {
|
||||
return bad("components must not start with '.' or end with '.lock'");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_repo_folder(folder: &str) -> Result<(), String> {
|
||||
let ok = !folder.is_empty()
|
||||
&& folder.len() <= 64
|
||||
&& folder != "."
|
||||
&& folder != ".."
|
||||
&& folder
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-'));
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Repository folder \"{}\" must be 1 to 64 characters of letters, digits, '.', '_' or '-', and not '.' or '..'",
|
||||
folder.escape_debug()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// "512, 1024 or 2048 MiB", or "2048 to 8192 MiB in steps of 1024".
|
||||
fn describe_sizes(sizes: &[u32]) -> String {
|
||||
let (first, last) = (sizes[0], sizes[sizes.len() - 1]);
|
||||
if sizes.len() <= 3 {
|
||||
let head: Vec<String> = sizes[..sizes.len() - 1].iter().map(u32::to_string).collect();
|
||||
return format!("{} or {} MiB", head.join(", "), last);
|
||||
}
|
||||
format!("{} to {} MiB in steps of {}", first, last, sizes[1] - sizes[0])
|
||||
}
|
||||
|
||||
pub fn validate_aws_workspace(cfg: &AwsWorkspaceConfig) -> Result<(), String> {
|
||||
let sizes = fargate_memory_sizes(cfg.cpu).ok_or_else(|| {
|
||||
format!(
|
||||
"{} is not a Fargate CPU size (256, 512, 1024, 2048, 4096, 8192 or 16384)",
|
||||
cfg.cpu
|
||||
)
|
||||
})?;
|
||||
if !sizes.contains(&cfg.memory_mib) {
|
||||
return Err(format!(
|
||||
"{} MiB of memory is not valid with {} CPU units: use {}",
|
||||
cfg.memory_mib,
|
||||
cfg.cpu,
|
||||
describe_sizes(&sizes)
|
||||
));
|
||||
}
|
||||
if !(21..=200).contains(&cfg.ephemeral_gib) {
|
||||
return Err("Ephemeral storage must be 21 to 200 GiB".into());
|
||||
}
|
||||
if let Some(m) = cfg.idle_stop_minutes {
|
||||
if !(5..=1440).contains(&m) {
|
||||
return Err("Idle stop must be 5 to 1440 minutes".into());
|
||||
}
|
||||
}
|
||||
if cfg.repos.len() > 20 {
|
||||
return Err("At most 20 repositories are allowed".into());
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for r in &cfg.repos {
|
||||
validate_repo_url(&r.url)?;
|
||||
if let Some(b) = &r.branch {
|
||||
validate_branch(b)?;
|
||||
}
|
||||
validate_repo_folder(&r.folder)?;
|
||||
if !seen.insert(r.folder.as_str()) {
|
||||
return Err(format!("Repository folder \"{}\" is used twice", r.folder));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What Stop does with an AWS workspace's unsaved git work (the stop guard).
|
||||
/// A local project ignores it.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WipChoice {
|
||||
/// Run `triple-c-wip-push --reason stop` first; a failure aborts the stop.
|
||||
Push,
|
||||
/// Stop without pushing from the app.
|
||||
Discard,
|
||||
}
|
||||
|
||||
/// One repository line of `triple-c-wip-push --dry-run`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RepoState {
|
||||
pub folder: String,
|
||||
pub dirty: bool,
|
||||
pub unpushed: u64,
|
||||
pub branch: String,
|
||||
/// The start's background clone is still writing it: nothing to push.
|
||||
#[serde(default)]
|
||||
pub cloning: bool,
|
||||
}
|
||||
|
||||
/// What `check_unsaved_work` found. `checked: false` (with `error`) when the
|
||||
/// workspace could not be asked — the stop guard then cannot offer a push.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct UnsavedWork {
|
||||
pub checked: bool,
|
||||
pub repos: Vec<RepoState>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProjectStatus {
|
||||
@@ -701,6 +986,9 @@ impl Project {
|
||||
renamed_session_names: HashMap::new(),
|
||||
marketplace_installs: Vec::new(),
|
||||
marketplace_disabled: Vec::new(),
|
||||
host_id: None,
|
||||
aws_placement: None,
|
||||
aws: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -717,6 +1005,11 @@ impl Project {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this project runs on an AWS host rather than local Docker.
|
||||
pub fn is_aws(&self) -> bool {
|
||||
self.host_id.is_some()
|
||||
}
|
||||
|
||||
pub fn container_name(&self) -> String {
|
||||
format!("triple-c-{}", self.id)
|
||||
}
|
||||
@@ -1011,4 +1304,156 @@ mod tests {
|
||||
assert!(!obj.contains_key("auth_bridge_enabled"));
|
||||
assert!(!obj.contains_key("browser_view_enabled"));
|
||||
}
|
||||
|
||||
// ── AWS host ────────────────────────────────────────────────────────────
|
||||
|
||||
fn cfg(cpu: u32, mem: u32) -> AwsWorkspaceConfig {
|
||||
AwsWorkspaceConfig { cpu, memory_mib: mem, ..Default::default() }
|
||||
}
|
||||
|
||||
fn repo(url: &str, branch: Option<&str>, folder: &str) -> RepoSource {
|
||||
RepoSource { url: url.into(), branch: branch.map(String::from), folder: folder.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_project_without_host_fields_loads_as_local() {
|
||||
let p = Project::new("demo".to_string(), Vec::new());
|
||||
let mut v = serde_json::to_value(&p).unwrap();
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("host_id");
|
||||
o.remove("aws_placement");
|
||||
o.remove("aws");
|
||||
let loaded: Project = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(loaded.host_id, None);
|
||||
assert!(loaded.aws.is_none() && loaded.aws_placement.is_none());
|
||||
assert!(!loaded.is_aws());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_aws_object_gets_the_defaults() {
|
||||
let c: AwsWorkspaceConfig = serde_json::from_str("{}").unwrap();
|
||||
assert!(c.wip_push_on_stop);
|
||||
assert_eq!((c.cpu, c.memory_mib, c.ephemeral_gib), (1024, 4096, 21));
|
||||
assert_eq!(c.arch, Arch::X86_64);
|
||||
assert_eq!(c.storage, StorageMode::Hybrid);
|
||||
assert!(!c.spot && c.idle_stop_minutes.is_none() && c.repos.is_empty());
|
||||
assert!(validate_aws_workspace(&c).is_ok());
|
||||
let off: AwsWorkspaceConfig = serde_json::from_str(r#"{"wip_push_on_stop":false}"#).unwrap();
|
||||
assert!(!off.wip_push_on_stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enums_serialize_snake_case_and_is_aws_follows_host_id() {
|
||||
assert_eq!(serde_json::to_string(&Arch::Arm64).unwrap(), "\"arm64\"");
|
||||
assert_eq!(serde_json::to_string(&Arch::X86_64).unwrap(), "\"x86_64\"");
|
||||
assert_eq!(serde_json::to_string(&StorageMode::Hybrid).unwrap(), "\"hybrid\"");
|
||||
let mut p = Project::new("d".into(), Vec::new());
|
||||
p.host_id = Some("h1".into());
|
||||
assert!(p.is_aws());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_and_memory_must_be_a_fargate_pair() {
|
||||
// Shared with the frontend's copy (`lib/awsWorkspace.test.ts`).
|
||||
let fixture: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../src/test/fixtures/fargate-pairs.json")).unwrap();
|
||||
let pairs = |k: &str| -> Vec<(u32, u32)> {
|
||||
fixture[k]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| (p[0].as_u64().unwrap() as u32, p[1].as_u64().unwrap() as u32))
|
||||
.collect()
|
||||
};
|
||||
for (c, m) in pairs("valid") {
|
||||
assert!(validate_aws_workspace(&cfg(c, m)).is_ok(), "{c}/{m} should be valid");
|
||||
}
|
||||
for (c, m) in pairs("invalid") {
|
||||
assert!(validate_aws_workspace(&cfg(c, m)).is_err(), "{c}/{m} should be rejected");
|
||||
}
|
||||
let e = validate_aws_workspace(&cfg(256, 1536)).unwrap_err();
|
||||
assert!(e.ends_with("use 512, 1024 or 2048 MiB"), "{e}");
|
||||
let e = validate_aws_workspace(&cfg(1024, 2500)).unwrap_err();
|
||||
assert!(e.ends_with("use 2048 to 8192 MiB in steps of 1024"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_storage_is_21_to_200() {
|
||||
for (g, ok) in [(20, false), (21, true), (200, true), (201, false)] {
|
||||
let c = AwsWorkspaceConfig { ephemeral_gib: g, ..Default::default() };
|
||||
assert_eq!(validate_aws_workspace(&c).is_ok(), ok, "{g}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_stop_is_5_to_1440_or_off() {
|
||||
for (m, ok) in [(None, true), (Some(4), false), (Some(5), true), (Some(1440), true), (Some(1441), false)] {
|
||||
let c = AwsWorkspaceConfig { idle_stop_minutes: m, ..Default::default() };
|
||||
assert_eq!(validate_aws_workspace(&c).is_ok(), ok, "{m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn with_repos(repos: Vec<RepoSource>) -> AwsWorkspaceConfig {
|
||||
AwsWorkspaceConfig { repos, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_folders_follow_the_charset_and_are_unique() {
|
||||
let ok = |f: &str| validate_aws_workspace(&with_repos(vec![repo("https://h/r.git", None, f)])).is_ok();
|
||||
assert!(ok("my-repo_1.x"));
|
||||
assert!(ok(&"a".repeat(64)));
|
||||
for bad in ["", ".", "..", "a/b", "a b", "ü", &"a".repeat(65)] {
|
||||
assert!(!ok(bad), "{bad:?}");
|
||||
}
|
||||
let dup = with_repos(vec![repo("https://h/a", None, "x"), repo("https://h/b", None, "x")]);
|
||||
assert!(validate_aws_workspace(&dup).unwrap_err().contains("twice"));
|
||||
let two = with_repos(vec![repo("https://h/a", None, "x"), repo("https://h/b", None, "y")]);
|
||||
assert!(validate_aws_workspace(&two).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_urls_are_https_scp_or_ssh() {
|
||||
let ok = |u: &str| validate_aws_workspace(&with_repos(vec![repo(u, None, "x")])).is_ok();
|
||||
for good in ["https://github.com/o/r.git", "git@github.com:o/r.git", "ssh://git@h/o/r.git"] {
|
||||
assert!(ok(good), "{good}");
|
||||
}
|
||||
for bad in ["", "http://h/r", "file:///etc", "/local/path", "--upload-pack=x", "github.com:o/r", "https://h/r x", "ext::sh -c id", "https://"] {
|
||||
assert!(!ok(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_https_url_with_credentials_is_refused_pointing_at_the_token_setting() {
|
||||
let check = |u: &str| validate_aws_workspace(&with_repos(vec![repo(u, None, "x")]));
|
||||
for bad in [
|
||||
"https://user:pass@github.com/o/r.git",
|
||||
"https://ghp_abc@github.com/o/r.git",
|
||||
"https://oauth2:tok@gitlab.com/o/r",
|
||||
] {
|
||||
let e = check(bad).unwrap_err();
|
||||
assert!(e.contains("Git HTTPS token"), "{e}");
|
||||
}
|
||||
// An @ in the path is not userinfo; ssh and scp forms keep their user.
|
||||
for good in ["https://h/o/r@v2.git", "ssh://git@h/o/r.git", "git@github.com:o/r.git"] {
|
||||
assert!(check(good).is_ok(), "{good}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_follow_check_ref_format() {
|
||||
let ok = |b: Option<&str>| validate_aws_workspace(&with_repos(vec![repo("https://h/r", b, "x")])).is_ok();
|
||||
for good in [None, Some("main"), Some("feat/aws-1"), Some("v1.2.3"), Some("a@b")] {
|
||||
assert!(ok(good), "{good:?}");
|
||||
}
|
||||
for bad in ["", "-x", "a..b", "a b", "a~1", "a^", "a:b", "a?", "a*", "a[", "a\\b", "a.lock", "x/.hidden", "a/", "/a", "a//b", "a@{1}", "@", "a.", "HEAD"] {
|
||||
assert!(!ok(Some(bad)), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_most_20_repos() {
|
||||
let mk = |n: usize| with_repos((0..n).map(|i| repo("https://h/r", None, &format!("r{i}"))).collect());
|
||||
assert!(validate_aws_workspace(&mk(20)).is_ok());
|
||||
assert!(validate_aws_workspace(&mk(21)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,32 @@ impl ProjectsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Granular setter for the AWS runtime handles of a project, store-owned
|
||||
/// like `container_id`. Fails if the project is not an AWS project.
|
||||
pub fn set_aws_runtime(
|
||||
&self,
|
||||
project_id: &str,
|
||||
task_arn: Option<String>,
|
||||
access_point_id: Option<String>,
|
||||
task_definition_arn: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
let p = projects
|
||||
.iter_mut()
|
||||
.find(|p| p.id == project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
let aws = p
|
||||
.aws
|
||||
.as_mut()
|
||||
.ok_or_else(|| format!("Project {} is not an AWS project", project_id))?;
|
||||
aws.task_arn = task_arn;
|
||||
aws.access_point_id = access_point_id;
|
||||
aws.task_definition_arn = task_definition_arn;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self.save(&projects)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
@@ -847,4 +873,33 @@ mod tests {
|
||||
assert!(sight_and_mark(&store, &full[..12], |_| false).is_some());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_aws_runtime_writes_the_handles_and_only_for_aws_projects() {
|
||||
let dir = temp_dir("aws-runtime");
|
||||
let mut aws = Project::new("a".to_string(), Vec::new());
|
||||
aws.host_id = Some("h".into());
|
||||
aws.aws = Some(Default::default());
|
||||
let local = Project::new("l".to_string(), Vec::new());
|
||||
let (aid, lid) = (aws.id.clone(), local.id.clone());
|
||||
let store = store_over(&dir, vec![aws, local]);
|
||||
|
||||
store
|
||||
.set_aws_runtime(&aid, Some("t".into()), Some("ap".into()), Some("td".into()))
|
||||
.unwrap();
|
||||
let a = store.get(&aid).unwrap().aws.unwrap();
|
||||
assert_eq!(
|
||||
(a.task_arn.as_deref(), a.access_point_id.as_deref(), a.task_definition_arn.as_deref()),
|
||||
(Some("t"), Some("ap"), Some("td"))
|
||||
);
|
||||
let on_disk: Vec<Project> =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
|
||||
assert_eq!(on_disk[0].aws.as_ref().unwrap().task_arn.as_deref(), Some("t"));
|
||||
|
||||
store.set_aws_runtime(&aid, None, None, None).unwrap();
|
||||
assert!(store.get(&aid).unwrap().aws.unwrap().task_arn.is_none());
|
||||
assert!(store.set_aws_runtime(&lid, None, None, None).is_err());
|
||||
assert!(store.set_aws_runtime("nope", None, None, None).is_err());
|
||||
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>,
|
||||
|
||||
@@ -295,7 +295,8 @@ async fn handle_open(
|
||||
return Err(format!("Project '{}' is not running", project.name));
|
||||
}
|
||||
|
||||
let container = crate::host::ContainerTarget::for_project(&project)
|
||||
let container = crate::host::ContainerTarget::for_project_waiting(&project)
|
||||
.await
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = match session_type {
|
||||
|
||||
+6
-1
@@ -25,7 +25,7 @@ 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";
|
||||
import { reconcileAwsProjects, reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||
|
||||
export default function App() {
|
||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||
@@ -81,6 +81,11 @@ export default function App() {
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
// AWS projects don't need Docker: ECS says which tasks run, and their
|
||||
// tunnels re-open in the background.
|
||||
reconcileAwsProjects()
|
||||
.then(setProjects)
|
||||
.catch((e) => console.error("Failed to reconcile AWS projects:", e));
|
||||
let stopPolling: (() => void) | undefined;
|
||||
checkDocker().then((available) => {
|
||||
if (available) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import AddProjectDialog from "./AddProjectDialog";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, EnvironmentScan } from "../../lib/types";
|
||||
import { environment } from "../../test/awsFixtures";
|
||||
|
||||
const add = vi.fn();
|
||||
|
||||
@@ -12,6 +15,11 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const detectAwsEnvironments = vi.fn();
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
detectAwsEnvironments: (c: string, r: string) => detectAwsEnvironments(c, r),
|
||||
}));
|
||||
|
||||
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
||||
function deferred() {
|
||||
let resolve!: (v: unknown) => void;
|
||||
@@ -116,3 +124,113 @@ describe("AddProjectDialog", () => {
|
||||
expect(submitButton()).toHaveAccessibleDescription("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddProjectDialog on AWS", () => {
|
||||
const scan = (envs: EnvironmentScan["environments"]): EnvironmentScan => ({
|
||||
region: "eu-west-1",
|
||||
account: "111122223333",
|
||||
owner_id: "AROA:alice",
|
||||
owner_key: "abc",
|
||||
state_bucket: "yes",
|
||||
boundary: "yes",
|
||||
environments: envs,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({
|
||||
appSettings: {
|
||||
hosts: [
|
||||
{
|
||||
id: "c1",
|
||||
name: "Work account",
|
||||
kind: { type: "aws", auth: { method: "profile", name: "work" }, default_region: "eu-west-1" },
|
||||
},
|
||||
],
|
||||
} as unknown as AppSettings,
|
||||
});
|
||||
});
|
||||
|
||||
function chooseAws() {
|
||||
fireEvent.change(screen.getByLabelText("Runs on"), { target: { value: "c1" } });
|
||||
}
|
||||
|
||||
it("sends the host, placement and workspace with repositories instead of folders", async () => {
|
||||
detectAwsEnvironments.mockResolvedValue(
|
||||
scan([environment("staging", "deploying", true), environment("dev", "ready", true)]),
|
||||
);
|
||||
add.mockResolvedValue({ id: "p1" });
|
||||
const onClose = vi.fn();
|
||||
render(<AddProjectDialog onClose={onClose} />);
|
||||
chooseAws();
|
||||
expect(screen.getByLabelText("Region")).toHaveValue("eu-west-1");
|
||||
await waitFor(() => expect(screen.getByLabelText("Environment")).toHaveValue("dev"));
|
||||
expect(detectAwsEnvironments).toHaveBeenCalledWith("c1", "eu-west-1");
|
||||
expect(
|
||||
(screen.getByRole("option", { name: /staging/ }) as HTMLOptionElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByLabelText("Folder 1 host path")).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository 1 URL"), {
|
||||
target: { value: "git@github.com:org/api.git" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Repository 1 branch"), { target: { value: "develop" } });
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalled());
|
||||
const [name, paths, host] = add.mock.calls[0];
|
||||
expect(name).toBe("api");
|
||||
expect(paths).toEqual([]);
|
||||
expect(host.hostId).toBe("c1");
|
||||
expect(host.awsPlacement).toEqual({ region: "eu-west-1", environment: "dev" });
|
||||
expect(host.aws.repos).toEqual([
|
||||
{ url: "git@github.com:org/api.git", branch: "develop", folder: "api" },
|
||||
]);
|
||||
expect(host.aws).toMatchObject({ cpu: 1024, memory_mib: 4096, wip_push_on_stop: true, task_arn: null });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("points at Settings → Hosts when no environment is usable", async () => {
|
||||
detectAwsEnvironments.mockResolvedValue(scan([environment("dev", "ready", false)]));
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
chooseAws();
|
||||
expect(await screen.findByText(/Settings → Hosts/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Environment")).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } });
|
||||
fireEvent.click(submitButton());
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Choose an environment/);
|
||||
expect(add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the backend's validation message when the add is refused", async () => {
|
||||
detectAwsEnvironments.mockResolvedValue(scan([environment("dev", "update_available", true)]));
|
||||
add.mockRejectedValue('Repository URL "ftp://x": use https://..., ssh://... or git@host:path');
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
chooseAws();
|
||||
await waitFor(() => expect(screen.getByLabelText("Environment")).toHaveValue("dev"));
|
||||
fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } });
|
||||
fireEvent.change(screen.getByLabelText("Repository 1 URL"), { target: { value: "ftp://x" } });
|
||||
fireEvent.click(submitButton());
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/use https:\/\//);
|
||||
});
|
||||
|
||||
it("forgets the environment when the region changes until that region's scan is done", async () => {
|
||||
detectAwsEnvironments.mockResolvedValueOnce(scan([environment("dev", "ready", true)]));
|
||||
let finish!: (v: EnvironmentScan) => void;
|
||||
detectAwsEnvironments.mockReturnValueOnce(new Promise((r) => (finish = r)));
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
chooseAws();
|
||||
await waitFor(() => expect(screen.getByLabelText("Environment")).toHaveValue("dev"));
|
||||
fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } });
|
||||
const region = screen.getByLabelText("Region");
|
||||
fireEvent.change(region, { target: { value: "us-east-1" } });
|
||||
fireEvent.blur(region);
|
||||
expect(await screen.findByText(/Looking for environments in us-east-1/)).toBeInTheDocument();
|
||||
fireEvent.click(submitButton());
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Choose an environment/);
|
||||
expect(add).not.toHaveBeenCalled();
|
||||
await act(async () => finish(scan([environment("prod", "ready", true)])));
|
||||
expect(screen.getByLabelText("Environment")).toHaveValue("prod");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import type { ProjectPath } from "../../lib/types";
|
||||
import type { DetectedEnvironment, HostConnection, ProjectPath, RepoSource } from "../../lib/types";
|
||||
import { detectAwsEnvironments } from "../../lib/tauri-commands";
|
||||
import { awsErrorMessage, toAwsError } from "../../lib/aws-error";
|
||||
import { cleanRepos, defaultAwsWorkspace, environmentSelectable } from "../../lib/awsWorkspace";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { inputClass, monoInputClass } from "../ui/Field";
|
||||
import { inputClass, monoInputClass, selectClass } from "../ui/Field";
|
||||
import { RepoRows, emptyRepo } from "./RepoListEditor";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -15,12 +20,36 @@ interface PathEntry {
|
||||
mount_name: string;
|
||||
}
|
||||
|
||||
/** Why an environment can't take a new project, for its disabled option. */
|
||||
function unavailableReason(env: DetectedEnvironment): string {
|
||||
const st = env.state.state;
|
||||
if (st !== "ready" && st !== "update_available") return st.replace(/_/g, " ");
|
||||
return "you have not joined it";
|
||||
}
|
||||
|
||||
type Scan =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "done"; environments: DetectedEnvironment[] };
|
||||
|
||||
function basenameFromPath(p: string): string {
|
||||
return p.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||
}
|
||||
|
||||
/** Stable, so the store selector does not return a new array each render. */
|
||||
const NO_HOSTS: HostConnection[] = [];
|
||||
|
||||
export default function AddProjectDialog({ onClose }: Props) {
|
||||
const { add } = useProjects();
|
||||
const hosts = useAppState((s) => s.appSettings?.hosts ?? NO_HOSTS);
|
||||
const [hostId, setHostId] = useState<string>("");
|
||||
const connection = hosts.find((h) => h.id === hostId) ?? null;
|
||||
const [region, setRegion] = useState("");
|
||||
const [scanRegion, setScanRegion] = useState("");
|
||||
const [scan, setScan] = useState<Scan>({ status: "idle" });
|
||||
const [environment, setEnvironment] = useState("");
|
||||
const [repos, setRepos] = useState<RepoSource[]>([emptyRepo()]);
|
||||
const [name, setName] = useState("");
|
||||
const [pathEntries, setPathEntries] = useState<PathEntry[]>([
|
||||
{ host_path: "", mount_name: "" },
|
||||
@@ -30,6 +59,41 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const formId = useId();
|
||||
|
||||
// Look for environments when the connection or the region (on blur) changes.
|
||||
useEffect(() => {
|
||||
if (!connection || !scanRegion) {
|
||||
setScan({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// An environment chosen in another region is not one in this region.
|
||||
setEnvironment("");
|
||||
setScan({ status: "loading" });
|
||||
detectAwsEnvironments(connection.id, scanRegion).then(
|
||||
(found) => {
|
||||
if (cancelled) return;
|
||||
setScan({ status: "done", environments: found.environments });
|
||||
const first = found.environments.find(environmentSelectable);
|
||||
setEnvironment(first?.name ?? "");
|
||||
},
|
||||
(e) => {
|
||||
if (!cancelled) setScan({ status: "error", message: awsErrorMessage(toAwsError(e)) });
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connection, scanRegion]);
|
||||
|
||||
const chooseHost = (id: string) => {
|
||||
setHostId(id);
|
||||
setEnvironment("");
|
||||
const conn = hosts.find((h) => h.id === id);
|
||||
const r = conn?.kind.default_region ?? "";
|
||||
setRegion(r);
|
||||
setScanRegion(r);
|
||||
};
|
||||
|
||||
const handleBrowse = async (index: number) => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
@@ -63,6 +127,39 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
setError("Project name is required");
|
||||
return;
|
||||
}
|
||||
if (connection) {
|
||||
// Only an environment from a finished scan of the region shown counts.
|
||||
if (region.trim() !== scanRegion) {
|
||||
setScanRegion(region.trim());
|
||||
setError("Choose an environment in the new region first");
|
||||
return;
|
||||
}
|
||||
if (scan.status !== "done" || !environment) {
|
||||
setError("Choose an environment to run the project in");
|
||||
return;
|
||||
}
|
||||
const cleaned = cleanRepos(repos);
|
||||
if (cleaned.some((r) => !r.url)) {
|
||||
setError("Every repository needs a URL");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await add(name.trim(), [], {
|
||||
hostId: connection.id,
|
||||
awsPlacement: { region: scanRegion, environment },
|
||||
aws: { ...defaultAwsWorkspace(), repos: cleaned },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// The backend's validation message (repository URL, folder, …).
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const validPaths: ProjectPath[] = pathEntries
|
||||
.filter((p) => p.host_path.trim())
|
||||
.map((p) => ({
|
||||
@@ -137,62 +234,166 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="block text-[13px] font-medium mb-1">Folders</span>
|
||||
<div className="space-y-2">
|
||||
{pathEntries.map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={entry.host_path}
|
||||
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||
placeholder="/path/to/folder"
|
||||
aria-label={`Folder ${i + 1} host path`}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button size="md" onClick={() => handleBrowse(i)}>
|
||||
Browse
|
||||
</Button>
|
||||
{pathEntries.length > 1 && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
aria-label={`Remove folder ${i + 1}`}
|
||||
onClick={() =>
|
||||
setPathEntries(pathEntries.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
|
||||
/workspace/
|
||||
</span>
|
||||
<input
|
||||
value={entry.mount_name}
|
||||
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||
placeholder="mount-name"
|
||||
aria-label={`Folder ${i + 1} mount name`}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hosts.length > 0 && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${formId}-host`}
|
||||
className="block text-[13px] font-medium mb-1"
|
||||
>
|
||||
Runs on
|
||||
</label>
|
||||
<select
|
||||
id={`${formId}-host`}
|
||||
value={hostId}
|
||||
onChange={(e) => chooseHost(e.target.value)}
|
||||
className={`w-full ${selectClass}`}
|
||||
>
|
||||
<option value="">This machine (Docker)</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name} (AWS)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
|
||||
}
|
||||
>
|
||||
+ Add folder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection ? (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${formId}-region`}
|
||||
className="block text-[13px] font-medium mb-1"
|
||||
>
|
||||
Region
|
||||
</label>
|
||||
<input
|
||||
id={`${formId}-region`}
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
onBlur={() => setScanRegion(region.trim())}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
setScanRegion(region.trim());
|
||||
}
|
||||
}}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${formId}-env`}
|
||||
className="block text-[13px] font-medium mb-1"
|
||||
>
|
||||
Environment
|
||||
</label>
|
||||
{scan.status === "loading" && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Looking for environments in {scanRegion}…
|
||||
</p>
|
||||
)}
|
||||
{scan.status === "error" && (
|
||||
<p className="text-xs text-[var(--error)]">{scan.message}</p>
|
||||
)}
|
||||
{scan.status === "done" && (
|
||||
<>
|
||||
{scan.environments.some(environmentSelectable) ? (
|
||||
<select
|
||||
id={`${formId}-env`}
|
||||
value={environment}
|
||||
onChange={(e) => setEnvironment(e.target.value)}
|
||||
className={`w-full ${selectClass}`}
|
||||
>
|
||||
{scan.environments.map((env) => (
|
||||
<option
|
||||
key={env.name}
|
||||
value={env.name}
|
||||
disabled={!environmentSelectable(env)}
|
||||
>
|
||||
{environmentSelectable(env)
|
||||
? env.name
|
||||
: `${env.name} — ${unavailableReason(env)}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
No environment in {scanRegion} is ready for you to use. Deploy or join
|
||||
one in Settings → Hosts.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[13px] font-medium mb-1">Repositories</span>
|
||||
<p className="mb-1.5 text-xs text-[var(--text-secondary)]">
|
||||
Cloned into <span className="font-mono">/workspace/<folder></span> when
|
||||
the workspace starts. Memory, storage and the rest are in the project’s
|
||||
Config tab.
|
||||
</p>
|
||||
<RepoRows rows={repos} onRowsChange={setRepos} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<span className="block text-[13px] font-medium mb-1">Folders</span>
|
||||
<div className="space-y-2">
|
||||
{pathEntries.map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={entry.host_path}
|
||||
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||
placeholder="/path/to/folder"
|
||||
aria-label={`Folder ${i + 1} host path`}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button size="md" onClick={() => handleBrowse(i)}>
|
||||
Browse
|
||||
</Button>
|
||||
{pathEntries.length > 1 && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
aria-label={`Remove folder ${i + 1}`}
|
||||
onClick={() =>
|
||||
setPathEntries(pathEntries.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
|
||||
/workspace/
|
||||
</span>
|
||||
<input
|
||||
value={entry.mount_name}
|
||||
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||
placeholder="mount-name"
|
||||
aria-label={`Folder ${i + 1} mount name`}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
|
||||
}
|
||||
>
|
||||
+ Add folder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ConfirmRemoveModal, { ConfirmForgetAwsModal } from "./ConfirmRemoveModal";
|
||||
|
||||
describe("ConfirmRemoveModal", () => {
|
||||
it("keeps an AWS workspace's access point unless asked", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ConfirmRemoveModal projectName="api" aws savedFiles onConfirm={onConfirm} onCancel={vi.fn()} />);
|
||||
const box = screen.getByRole("checkbox", { name: /file-system access point/ });
|
||||
expect(box).not.toBeChecked();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
|
||||
fireEvent.click(box);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(onConfirm.mock.calls).toEqual([[{ discardData: false }], [{ discardData: true }]]);
|
||||
});
|
||||
|
||||
it("offers no access-point choice for an Ephemeral AWS workspace", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ConfirmRemoveModal projectName="api" aws onConfirm={onConfirm} onCancel={vi.fn()} />);
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(onConfirm).toHaveBeenCalledWith({ discardData: false });
|
||||
});
|
||||
|
||||
it("offers no access-point choice for a local project", () => {
|
||||
render(<ConfirmRemoveModal projectName="api" onConfirm={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(screen.getByText(/mounted in, not copied/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says what is left in AWS when the connection is gone", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmForgetAwsModal
|
||||
projectName="api"
|
||||
reason="This project's AWS connection no longer exists"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/task definitions, its secret and its saved files/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove and leave them in AWS" }));
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
onConfirm: () => void;
|
||||
/** An AWS project: its task, task definitions and secret go instead of Docker state. */
|
||||
aws?: boolean;
|
||||
/** AWS: the workspace may have an EFS access point (offers removing it). */
|
||||
savedFiles?: boolean;
|
||||
onConfirm: (opts: { discardData: boolean }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||
const REMOVE_CLASS =
|
||||
"bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90";
|
||||
|
||||
export default function ConfirmRemoveModal({
|
||||
projectName,
|
||||
aws = false,
|
||||
savedFiles = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [discardData, setDiscardData] = useState(false);
|
||||
return (
|
||||
<Modal
|
||||
title="Remove Project"
|
||||
@@ -20,32 +35,103 @@ export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }:
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={onConfirm}
|
||||
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||
onClick={() => onConfirm({ discardData: savedFiles && discardData })}
|
||||
className={REMOVE_CLASS}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/*
|
||||
Everything remove_project() destroys, named. It removes the container,
|
||||
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
|
||||
the triple-c-snapshot-{id} image and the project's keychain secrets — so
|
||||
an accurate warning has to reach past "the config volume". The last
|
||||
sentence is the reassuring half and matters just as much: project folders
|
||||
are bind mounts from the host and nothing here touches them.
|
||||
*/}
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Are you sure you want to remove{" "}
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
|
||||
its container, both of its volumes and its saved container image — so the home
|
||||
directory, the Claude login and config, installed skills, session transcripts,
|
||||
scheduled tasks and any stored credentials all go with it.
|
||||
</p>
|
||||
{aws ? (
|
||||
<>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Are you sure you want to remove{" "}
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This stops its
|
||||
workspace on AWS and deletes its secret. Its task definitions stay registered (they hold no secrets and cost
|
||||
nothing); an administrator can deregister them. Work that was not pushed to a
|
||||
remote is lost.
|
||||
</p>
|
||||
{savedFiles && (
|
||||
<label className="mt-3 flex items-start gap-2 text-[13px] text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={discardData}
|
||||
onChange={(e) => setDiscardData(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Also remove the workspace’s file-system access point
|
||||
<span className="block text-xs text-[var(--text-secondary)]">
|
||||
The saved files themselves (Claude login, history, SSH keys) stay on your own
|
||||
EFS file system, readable only by your workspace role, until an administrator
|
||||
offboards you.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/*
|
||||
Everything remove_project() destroys, named. It removes the container,
|
||||
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
|
||||
the triple-c-snapshot-{id} image and the project's keychain secrets — so
|
||||
an accurate warning has to reach past "the config volume". The last
|
||||
sentence is the reassuring half and matters just as much: project folders
|
||||
are bind mounts from the host and nothing here touches them.
|
||||
*/}
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Are you sure you want to remove{" "}
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
|
||||
its container, both of its volumes and its saved container image — so the home
|
||||
directory, the Claude login and config, installed skills, session transcripts,
|
||||
scheduled tasks and any stored credentials all go with it.
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
|
||||
Your project folders on this machine are mounted in, not copied, and are left
|
||||
untouched.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The second confirm when an AWS project's connection is gone. */
|
||||
export function ConfirmForgetAwsModal({
|
||||
projectName,
|
||||
reason,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
projectName: string;
|
||||
reason: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
title="Remove without its AWS resources?"
|
||||
onClose={onCancel}
|
||||
widthClassName="w-[28rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" onClick={onConfirm} className={REMOVE_CLASS}>
|
||||
Remove and leave them in AWS
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">{reason}</p>
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
|
||||
Your project folders on this machine are mounted in, not copied, and are left
|
||||
untouched.
|
||||
Removing <strong className="text-[var(--text-primary)]">{projectName}</strong> now
|
||||
leaves its AWS resources in the account: the task (which may still be running and
|
||||
billed), its task definitions, its secret and its saved files. You would have to
|
||||
remove them in AWS yourself.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useAppState, homeTabKey } from "../../store/appState";
|
||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import { useUnavailable } from "../ui/unavailable";
|
||||
import { isAwsProject } from "../../lib/awsWorkspace";
|
||||
import StopGuardModal from "./StopGuardModal";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -23,8 +25,13 @@ export default function ProjectRow({ project }: Props) {
|
||||
progress: s.containerProgress[project.id],
|
||||
})),
|
||||
);
|
||||
const { busy, handleStart, handleStop, openClaudeTerminal } =
|
||||
const { busy, handleStart, handleStop, openClaudeTerminal, stopGuard, resolveStopGuard } =
|
||||
useProjectActions(project);
|
||||
const aws = isAwsProject(project);
|
||||
// An AWS project mounts nothing; its clones are what /workspace holds.
|
||||
const folders = aws
|
||||
? (project.aws?.repos ?? []).map((r) => r.folder)
|
||||
: project.paths.map((pp) => pp.mount_name);
|
||||
|
||||
const isSelected =
|
||||
activeTabKey === homeTabKey(project.id) || selectedProjectId === project.id;
|
||||
@@ -64,12 +71,17 @@ export default function ProjectRow({ project }: Props) {
|
||||
<span className="w-[3.75rem] flex-shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-0.5 ml-4 space-y-0.5 min-w-0">
|
||||
{project.paths.map((pp, i) => (
|
||||
{aws && (
|
||||
<div className="text-xs text-[var(--text-secondary)] truncate">
|
||||
AWS · {project.aws_placement?.environment ?? "?"} ({project.aws_placement?.region ?? "?"})
|
||||
</div>
|
||||
)}
|
||||
{folders.map((folder, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs text-[var(--text-secondary)] truncate font-mono"
|
||||
>
|
||||
/workspace/{pp.mount_name}
|
||||
/workspace/{folder}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs">
|
||||
@@ -149,6 +161,14 @@ export default function ProjectRow({ project }: Props) {
|
||||
</button>
|
||||
{terminal.reasonNode}
|
||||
</div>
|
||||
{stopGuard && (
|
||||
<StopGuardModal
|
||||
projectName={project.name}
|
||||
work={stopGuard}
|
||||
wipPushOnStop={project.aws?.wip_push_on_stop ?? false}
|
||||
onChoose={resolveStopGuard}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RepoSource } from "../../lib/types";
|
||||
import { cleanRepos, repoFolderFromUrl } from "../../lib/awsWorkspace";
|
||||
import Button from "../ui/Button";
|
||||
import { inputClass, monoInputClass } from "../ui/Field";
|
||||
|
||||
export interface RepoRowsProps {
|
||||
rows: RepoSource[];
|
||||
onRowsChange: (rows: RepoSource[]) => void;
|
||||
/** Called when focus leaves a field (save-on-blur editors). */
|
||||
onCommit?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const emptyRepo = (): RepoSource => ({ url: "", branch: null, folder: "" });
|
||||
|
||||
/** Rows of URL / branch / folder. The folder follows the URL until it is edited. */
|
||||
export function RepoRows({ rows, onRowsChange, onCommit, disabled = false }: RepoRowsProps) {
|
||||
const update = (i: number, patch: Partial<RepoSource>) => {
|
||||
const next = [...rows];
|
||||
next[i] = { ...next[i], ...patch };
|
||||
onRowsChange(next);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||
>
|
||||
<input
|
||||
value={r.url}
|
||||
aria-label={`Repository ${i + 1} URL`}
|
||||
placeholder="git@github.com:org/repo.git or https://…"
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const url = e.target.value;
|
||||
const followed = !r.folder || r.folder === repoFolderFromUrl(r.url);
|
||||
update(i, { url, ...(followed ? { folder: repoFolderFromUrl(url) } : {}) });
|
||||
}}
|
||||
onBlur={onCommit}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={r.branch ?? ""}
|
||||
aria-label={`Repository ${i + 1} branch`}
|
||||
placeholder="default branch"
|
||||
disabled={disabled}
|
||||
onChange={(e) => update(i, { branch: e.target.value })}
|
||||
onBlur={onCommit}
|
||||
className={`w-40 ${inputClass}`}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
|
||||
/workspace/
|
||||
</span>
|
||||
<input
|
||||
value={r.folder}
|
||||
aria-label={`Repository ${i + 1} folder`}
|
||||
placeholder="folder"
|
||||
disabled={disabled}
|
||||
onChange={(e) => update(i, { folder: e.target.value })}
|
||||
onBlur={onCommit}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove repository ${i + 1}`}
|
||||
onClick={() => {
|
||||
onRowsChange(rows.filter((_, j) => j !== i));
|
||||
// A removal is a finished edit; `onRowsChange` has already
|
||||
// handed the shorter list over.
|
||||
onCommit?.();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button disabled={disabled} onClick={() => onRowsChange([...rows, emptyRepo()])}>
|
||||
+ Add repository
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sameRepos(a: RepoSource[], b: RepoSource[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((r, i) => r.url === b[i].url && r.branch === b[i].branch && r.folder === b[i].folder)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Config tab's save-on-blur repository list. Saves when every row that
|
||||
* has anything in it has a URL; the backend's validation message (bad URL,
|
||||
* folder used twice…) comes back through the save's toast.
|
||||
*
|
||||
* Rows in progress survive a refresh of the project list: they are replaced
|
||||
* from `repos` only when the stored value actually changed.
|
||||
*/
|
||||
export default function RepoListEditor({
|
||||
repos,
|
||||
onSave,
|
||||
disabled = false,
|
||||
}: {
|
||||
repos: RepoSource[];
|
||||
onSave: (repos: RepoSource[]) => Promise<boolean>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [rows, setRows] = useState<RepoSource[]>(repos);
|
||||
// The latest rows, so a commit right after an edit (a removal) sees them.
|
||||
const rowsRef = useRef(rows);
|
||||
const storedRef = useRef(repos);
|
||||
|
||||
useEffect(() => {
|
||||
if (sameRepos(repos, storedRef.current)) return;
|
||||
storedRef.current = repos;
|
||||
rowsRef.current = repos;
|
||||
setRows(repos);
|
||||
}, [repos]);
|
||||
|
||||
const change = (next: RepoSource[]) => {
|
||||
rowsRef.current = next;
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const cleaned = cleanRepos(rowsRef.current);
|
||||
if (cleaned.some((r) => !r.url)) return;
|
||||
if (sameRepos(cleaned, storedRef.current)) return;
|
||||
void onSave(cleaned);
|
||||
};
|
||||
|
||||
return <RepoRows rows={rows} onRowsChange={change} onCommit={commit} disabled={disabled} />;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import StopGuardModal from "./StopGuardModal";
|
||||
import type { UnsavedWork } from "../../lib/types";
|
||||
|
||||
const dirty: UnsavedWork = {
|
||||
checked: true,
|
||||
repos: [
|
||||
{ folder: "api", branch: "main", dirty: true, unpushed: 2 },
|
||||
{ folder: "clean", branch: "main", dirty: false, unpushed: 0 },
|
||||
],
|
||||
error: null,
|
||||
};
|
||||
|
||||
describe("StopGuardModal", () => {
|
||||
it("lists the repositories at risk and defaults to pushing", async () => {
|
||||
const onChoose = vi.fn();
|
||||
render(<StopGuardModal projectName="api" work={dirty} onChoose={onChoose} />);
|
||||
expect(screen.getByText("/workspace/api")).toBeInTheDocument();
|
||||
expect(screen.getByText(/uncommitted changes, 2 unpushed commits/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("/workspace/clean")).toBeNull();
|
||||
const push = screen.getByRole("button", { name: "Push to WIP branch and stop" });
|
||||
await waitFor(() => expect(push).toHaveFocus());
|
||||
fireEvent.click(push);
|
||||
expect(onChoose).toHaveBeenCalledWith("push");
|
||||
});
|
||||
|
||||
it("lists a repository still cloning as having nothing to push", () => {
|
||||
const work: UnsavedWork = {
|
||||
...dirty,
|
||||
repos: [...dirty.repos, { folder: "big", branch: "", dirty: false, unpushed: 0, cloning: true }],
|
||||
};
|
||||
render(<StopGuardModal projectName="api" work={work} onChoose={vi.fn()} />);
|
||||
expect(screen.getByText("/workspace/big")).toBeInTheDocument();
|
||||
expect(screen.getByText("still cloning — nothing to push")).toBeInTheDocument();
|
||||
expect(screen.getByText("/workspace/api")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Stop without pushing and Cancel", () => {
|
||||
const onChoose = vi.fn();
|
||||
render(<StopGuardModal projectName="api" work={dirty} onChoose={onChoose} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop without pushing" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onChoose.mock.calls).toEqual([["discard"], [null]]);
|
||||
});
|
||||
|
||||
it("hides Push and says why when the workspace could not be checked", async () => {
|
||||
const onChoose = vi.fn();
|
||||
render(
|
||||
<StopGuardModal
|
||||
projectName="api"
|
||||
work={{ checked: false, repos: [], error: "Triple-C is not connected to the workspace" }}
|
||||
wipPushOnStop
|
||||
onChoose={onChoose}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /Push/ })).toBeNull();
|
||||
expect(screen.getByText(/own push of work in progress still runs/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Triple-C is not connected to the workspace")).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not check/)).toBeInTheDocument();
|
||||
const stop = screen.getByRole("button", { name: "Stop anyway" });
|
||||
await waitFor(() => expect(stop).toHaveFocus());
|
||||
// Nothing is recorded: the shutdown push the copy promises still runs.
|
||||
fireEvent.click(stop);
|
||||
expect(onChoose).toHaveBeenCalledWith("unguarded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useRef } from "react";
|
||||
import type { UnsavedWork, WipChoice } from "../../lib/types";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
export type StopGuardChoice = WipChoice | "unguarded" | null;
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
work: UnsavedWork;
|
||||
/** The project's "push work in progress when the workspace stops" setting. */
|
||||
wipPushOnStop?: boolean;
|
||||
/** `null` = Cancel. `"unguarded"` = stop without recording any choice
|
||||
* (the unchecked case: the workspace's own shutdown push still runs). */
|
||||
onChoose: (choice: StopGuardChoice) => void;
|
||||
}
|
||||
|
||||
function describeRepo(dirty: boolean, unpushed: number): string {
|
||||
const parts: string[] = [];
|
||||
if (dirty) parts.push("uncommitted changes");
|
||||
if (unpushed > 0) parts.push(`${unpushed} unpushed commit${unpushed === 1 ? "" : "s"}`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked before an AWS workspace stops when its repositories hold work no
|
||||
* remote has, or when Triple-C could not check. Pushing to a WIP branch is
|
||||
* the default (it has focus); it is not offered when the check could not
|
||||
* run, since the push goes the same way the check did.
|
||||
*/
|
||||
export default function StopGuardModal({ projectName, work, wipPushOnStop = false, onChoose }: Props) {
|
||||
const pushRef = useRef<HTMLButtonElement>(null);
|
||||
const stopRef = useRef<HTMLButtonElement>(null);
|
||||
const atRisk = work.repos.filter((r) => !r.cloning && (r.dirty || r.unpushed > 0));
|
||||
const cloning = work.repos.filter((r) => r.cloning);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Unsaved work"
|
||||
onClose={() => onChoose(null)}
|
||||
widthClassName="w-[30rem]"
|
||||
initialFocusRef={work.checked ? pushRef : stopRef}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={() => onChoose(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
ref={stopRef}
|
||||
size="md"
|
||||
variant={work.checked ? "danger" : "primary"}
|
||||
onClick={() => onChoose(work.checked ? "discard" : "unguarded")}
|
||||
>
|
||||
{work.checked ? "Stop without pushing" : "Stop anyway"}
|
||||
</Button>
|
||||
{work.checked && (
|
||||
<Button ref={pushRef} size="md" variant="primary" onClick={() => onChoose("push")}>
|
||||
Push to WIP branch and stop
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{work.checked ? (
|
||||
<>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Stopping <strong className="text-[var(--text-primary)]">{projectName}</strong> ends
|
||||
its workspace. These repositories have work that no remote has yet:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{atRisk.map((r) => (
|
||||
<li key={r.folder} className="text-[13px] text-[var(--text-primary)]">
|
||||
<span className="font-mono">/workspace/{r.folder}</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
({r.branch}): {describeRepo(r.dirty, r.unpushed)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{cloning.map((r) => (
|
||||
<li key={r.folder} className="text-[13px] text-[var(--text-primary)]">
|
||||
<span className="font-mono">/workspace/{r.folder}</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
still cloning — nothing to push
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-[13px] text-[var(--text-secondary)]">
|
||||
Push to WIP branch saves a snapshot of each repository to a{" "}
|
||||
<span className="font-mono">triple-c/wip/…</span> branch on its remote, without
|
||||
touching your branches. If the push fails, the workspace keeps running. Stop
|
||||
without pushing skips that push, including the one the workspace would make as it
|
||||
shuts down.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Triple-C could not check{" "}
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong> for unsaved
|
||||
work, so it can’t push it to a WIP branch first.
|
||||
</p>
|
||||
{work.error && (
|
||||
<p
|
||||
role="note"
|
||||
className="mt-2 px-2 py-1.5 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)]"
|
||||
>
|
||||
{work.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
|
||||
Uncommitted or unpushed work in the workspace may be lost if you stop it now.
|
||||
{wipPushOnStop &&
|
||||
" The workspace's own push of work in progress still runs as it shuts down, if it can reach the remote."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import ModelSection from "./config/ModelSection";
|
||||
import AccessSection from "./config/AccessSection";
|
||||
import RuntimeSection from "./config/RuntimeSection";
|
||||
import MarketplaceSection from "./config/MarketplaceSection";
|
||||
import HostSection from "./config/HostSection";
|
||||
import { isAwsProject } from "../../../lib/awsWorkspace";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -40,6 +42,9 @@ export default function ConfigTab({ project, save, saveState }: Props) {
|
||||
</div>
|
||||
|
||||
<WorkspaceSection project={project} save={save} disabled={disabled} />
|
||||
{isAwsProject(project) && (
|
||||
<HostSection project={project} save={save} disabled={disabled} />
|
||||
)}
|
||||
<ModelSection project={project} save={save} disabled={disabled} />
|
||||
<AccessSection
|
||||
project={project}
|
||||
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user