Skip to content

Commit c042b17

Browse files
waleedlatif1claude
andcommitted
fix(supply-chain): derive the mirror path instead of describing a rule for it
The mirror instruction was wrong for the five first-party images. The inventory lists what you pull — `ghcr.io/simstudioai/app` — but `ghcr.io` is the chart's default registry, so a redirected install pulls `<registry>/simstudioai/app`. Following "mirror to <registry>/<reference as listed>" put them at `<registry>/ghcr.io/simstudioai/...` and every Sim image failed to pull. The device plugin is the opposite case: its `nvcr.io/` really is part of the repository and survives the rewrite. Rather than write that rule out and hope it stays true, the generator now renders the chart a second time against a sentinel registry and strips it back off, so each entry carries both the source reference and the exact path the chart resolves to. Verified by rendering with a real mirror and diffing: all 13 mirror paths match. Also retries the moving-alias read. A mismatch meant "the guard held this tag back", but it can equally mean GHCR is still serving the previous digest a moment after this run wrote it — and that reading dropped three subjects from the matrix without a word. Docs, each verified against source: - Chat drops a disallowed tool while generating a workflow and records the omission; a run that reaches one fails. Both statements were needed, and the page previously asserted one or the other in different places. - A quickly-failing CronJob pod is retried six times by Kubernetes; the five-minute deadline only preempts one that hangs. - Selecting a sandbox without a provider is a 503; the 422 is the file path. - Chat's public code tool is run_function, not the internal function_execute. - The parity step needs SANDBOX_PROVIDER=e2b, or an exported daytona provider makes step 1 exit before writing the manifest. - PII_URL is auto-wired by the chart and needs a service URL on Compose. - NO_PROXY should use the cluster suffix; the Service names depend on the release name. - The bucket loops now fail loudly instead of silently iterating nothing when SIM_BUCKETS is unset in a fresh shell. - Generating CRON_SECRET beside the curl suggested it was the token to use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt
1 parent 2dc5476 commit c042b17

9 files changed

Lines changed: 170 additions & 48 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,18 @@ jobs:
746746
fi
747747
748748
for alias in $alias_groups; do
749-
alias_index="$(digest_of "${image}:${alias}")"
749+
# A mismatch has two very different causes: the guard deliberately
750+
# held the tag back, or GHCR is still serving the previous digest
751+
# moments after this run wrote it. Re-read before concluding the
752+
# former, or a read landing a second early silently drops three
753+
# subjects from the matrix.
754+
alias_index=""
755+
for alias_attempt in 1 2 3; do
756+
alias_index="$(digest_of "${image}:${alias}")"
757+
[ "$alias_index" = "$sha_index" ] && break
758+
[ "$alias_attempt" -lt 3 ] && sleep 5 || true
759+
done
760+
750761
if [ "$alias_index" != "$sha_index" ]; then
751762
# `latest` is legitimately allowed to lag: the monotonic guard in
752763
# create-ghcr-manifests holds it back when the branch has moved

apps/docs/content/docs/platform/enterprise/access-control.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Assignment-time checks keep this unambiguous: a workspace has at most one all-me
2727
When a user runs a workflow or uses Chat, Sim reads the resolved group's configuration and applies it:
2828

2929
- **In the executor:** If a workflow uses a disallowed block type or model provider, execution halts immediately with an error. This applies to both manual runs and scheduled or API-triggered deployments.
30-
- **In Chat:** Disallowed blocks are filtered out of the block list so they cannot be added to a workflow. Disallowed tool types (MCP, custom tools, skills) make the block fail with an error if Sim attempts to use them — they are refused, not silently dropped.
30+
- **In Chat:** Disallowed blocks are filtered out of the block list so they cannot be added to a workflow. Disallowed tool types (MCP, custom tools, skills) are dropped while Sim generates a workflow, with the omission recorded. If a run reaches one anyway, the block fails with an error naming the restriction rather than skipping it.
3131

3232
---
3333

@@ -209,7 +209,7 @@ This applies regardless of how the workflow is triggered — manually, via API,
209209
When a user opens Chat, their permission group is read before any block or tool suggestions are made:
210210

211211
- Blocks not in the allowed list are filtered out of the block picker entirely — they do not appear as options.
212-
- If Sim generates a workflow step that would use a disallowed tool (MCP, custom, or skills), the run fails with an error naming the restriction.
212+
- If Sim generates a workflow step that would use a disallowed tool (MCP, custom, or skills), the tool is left out of the generated workflow. A run that reaches a disallowed tool fails with an error naming the restriction.
213213

214214
---
215215

apps/docs/content/docs/platform/enterprise/data-retention.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ Neither shipped deployment schedules these three endpoints — not the Helm char
199199
</Callout>
200200

201201
```bash
202-
CRON_SECRET=$(openssl rand -hex 32)
203-
202+
# Use the value already configured as CRON_SECRET on the app — a token
203+
# generated here and not installed there returns 401.
204204
curl -H "Authorization: Bearer $CRON_SECRET" \
205205
https://sim.example.com/api/logs/cleanup
206206
```

apps/docs/content/docs/platform/self-hosting/background-jobs.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ cronjobs:
7171
enabled: true
7272
```
7373
74-
Each job runs a small `curlimages/curl` pod that calls the app's **in-cluster Service** (not the ingress), with `concurrencyPolicy: Forbid` so a slow run never overlaps itself. The Service load-balances that single call to one replica, so a job never fans out across pods. The chart keeps three completed and one failed Job per CronJob, but `ttlSecondsAfterFinished` defaults to 600, so a finished Job is deleted ten minutes after it ends — inspect a failure promptly or collect the logs. It sets no `backoffLimit`, but `cronjobs.activeDeadlineSeconds` (default `300`) terminates the whole Job after five minutes, and the pod's own script already retries three times inside that window — so a failing job gets roughly one pod attempt and is then marked `DeadlineExceeded`, not six retries.
74+
Each job runs a small `curlimages/curl` pod that calls the app's **in-cluster Service** (not the ingress), with `concurrencyPolicy: Forbid` so a slow run never overlaps itself. The Service load-balances that single call to one replica, so a job never fans out across pods. The chart keeps three completed and one failed Job per CronJob, but `ttlSecondsAfterFinished` defaults to 600, so a finished Job is deleted ten minutes after it ends — inspect a failure promptly or collect the logs. It sets no `backoffLimit`, so a pod that exits quickly — an immediate `401`, say — is retried up to the Kubernetes default of six times. A pod that hangs instead is cut off by `cronjobs.activeDeadlineSeconds` (default `300`), which caps the whole Job at five minutes and marks it `DeadlineExceeded`. Set `backoffLimit: 0` if you want exactly one attempt.
7575

7676
Disable individual jobs you do not need — billing reconciliation is the obvious one on a self-hosted install:
7777

apps/docs/content/docs/platform/self-hosting/environment-variables.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ See [Observability](/platform/self-hosting/observability).
199199
|----------|-------------|
200200
| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run |
201201
| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `npx sim-setup` sets it for you if you skip the chat key |
202-
| `PII_URL` | Base URL of the Presidio service backing PII detection and redaction. Defaults to `http://localhost:5001`, which exists only in local development — set it to your own Presidio deployment |
202+
| `PII_URL` | Base URL of the Presidio service backing PII detection and redaction. The Helm chart wires it to its own `pii` Service when `pii.enabled`; on Compose point it at the PII service on your network. The default `http://localhost:5001` exists only in local development, and leaving it makes redaction fail |
203203
| `DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES` | Durable stores where a value whose secret provenance was never recorded fails the run instead of logging a warning. `all`, or a comma-separated subset of `memory`, `table-row`, `knowledge`, `workspace-file`. Unset (nothing enforced) by default |
204204
| `ADMIN_API_KEY` | Admin API key for GitOps operations and organization provisioning |
205205

apps/docs/content/docs/platform/self-hosting/object-storage.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export SIM_BUCKETS="workspace-files knowledge-base execution-files chat-files
5050
copilot-files profile-pictures og-images workspace-logos"
5151

5252
# Create buckets (names must be globally unique — prefix with your org)
53-
for name in $SIM_BUCKETS; do
53+
for name in ${SIM_BUCKETS:?run the export above first}; do
5454
aws s3api create-bucket \
5555
--bucket "myorg-sim-$name" \
5656
--region "$AWS_REGION" \
@@ -86,7 +86,7 @@ cat > /tmp/cors.json <<'EOF'
8686
}
8787
EOF
8888

89-
for name in $SIM_BUCKETS; do
89+
for name in ${SIM_BUCKETS:?run the export above first}; do
9090
aws s3api put-bucket-cors --bucket "myorg-sim-$name" --cors-configuration file:///tmp/cors.json
9191
done
9292
```
@@ -280,7 +280,7 @@ export SIM_BUCKETS="workspace-files knowledge-base execution-files chat-files
280280
copilot-files profile-pictures og-images workspace-logos"
281281

282282
# Create buckets (names must be globally unique — prefix with your org)
283-
for name in $SIM_BUCKETS; do
283+
for name in ${SIM_BUCKETS:?run the export above first}; do
284284
gcloud storage buckets create "gs://myorg-sim-$name" \
285285
--project "$PROJECT_ID" \
286286
--location "$LOCATION" \
@@ -318,7 +318,7 @@ cat > /tmp/cors.json <<'EOF'
318318
]
319319
EOF
320320

321-
for name in $SIM_BUCKETS; do
321+
for name in ${SIM_BUCKETS:?run the export above first}; do
322322
gcloud storage buckets update "gs://myorg-sim-$name" --cors-file=/tmp/cors.json
323323
done
324324
```
@@ -338,7 +338,7 @@ Create a service account (or reuse the one your workload runs as) and grant it o
338338
```bash
339339
gcloud iam service-accounts create sim-storage --project "$PROJECT_ID"
340340

341-
for name in $SIM_BUCKETS; do
341+
for name in ${SIM_BUCKETS:?run the export above first}; do
342342
gcloud storage buckets add-iam-policy-binding "gs://myorg-sim-$name" \
343343
--member "serviceAccount:sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \
344344
--role roles/storage.objectAdmin

helm/sim/images.yaml

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,38 @@
1010
# their public registries. Pin each image's `digest` to what your mirror
1111
# resolved.
1212
#
13-
# Mirror to `<your-registry>/<reference exactly as listed below>`. The chart
14-
# prepends your registry to the repository verbatim, so the device plugin lands
15-
# at `<your-registry>/nvcr.io/nvidia/k8s-device-plugin` — the `nvcr.io/`
16-
# segment is part of the path, not a source host to strip. If your registry
17-
# cannot nest that path, override the one image directly instead:
18-
# `ollama.gpu.devicePlugin.image.repository=<your-registry>/nvidia/k8s-device-plugin`.
13+
# Each entry pairs the reference to pull FROM with the path the chart resolves
14+
# to once redirected. Copy `source` to `<your-registry>/<mirror>` — they differ:
15+
# `ghcr.io/` is the default registry and is replaced, while the device plugin's
16+
# `nvcr.io/` is part of its repository and is kept. If your registry cannot nest
17+
# that path, override that one image:
18+
# `ollama.gpu.devicePlugin.image.repository=<your-registry>/nvidia/k8s-device-plugin`
19+
# with global rewriting left on, since the override is used verbatim.
1920
appVersion: v0.8.18
2021
images:
21-
- busybox:1.36
22-
- curlimages/curl:8.5.0
23-
- ghcr.io/simstudioai/copilot:v0.8.18
24-
- ghcr.io/simstudioai/migrations:v0.8.18
25-
- ghcr.io/simstudioai/pii:v0.8.18
26-
- ghcr.io/simstudioai/realtime:v0.8.18
27-
- ghcr.io/simstudioai/simstudio:v0.8.18
28-
- nvcr.io/nvidia/k8s-device-plugin:v0.18.2
29-
- ollama/ollama:0.23.2
30-
- otel/opentelemetry-collector-contrib:0.91.0
31-
- pgvector/pgvector:pg17
32-
- postgres:17-alpine
33-
- redis:7-alpine
22+
- source: busybox:1.36
23+
mirror: busybox:1.36
24+
- source: curlimages/curl:8.5.0
25+
mirror: curlimages/curl:8.5.0
26+
- source: ghcr.io/simstudioai/copilot:v0.8.18
27+
mirror: simstudioai/copilot:v0.8.18
28+
- source: ghcr.io/simstudioai/migrations:v0.8.18
29+
mirror: simstudioai/migrations:v0.8.18
30+
- source: ghcr.io/simstudioai/pii:v0.8.18
31+
mirror: simstudioai/pii:v0.8.18
32+
- source: ghcr.io/simstudioai/realtime:v0.8.18
33+
mirror: simstudioai/realtime:v0.8.18
34+
- source: ghcr.io/simstudioai/simstudio:v0.8.18
35+
mirror: simstudioai/simstudio:v0.8.18
36+
- source: nvcr.io/nvidia/k8s-device-plugin:v0.18.2
37+
mirror: nvcr.io/nvidia/k8s-device-plugin:v0.18.2
38+
- source: ollama/ollama:0.23.2
39+
mirror: ollama/ollama:0.23.2
40+
- source: otel/opentelemetry-collector-contrib:0.91.0
41+
mirror: otel/opentelemetry-collector-contrib:0.91.0
42+
- source: pgvector/pgvector:pg17
43+
mirror: pgvector/pgvector:pg17
44+
- source: postgres:17-alpine
45+
mirror: postgres:17-alpine
46+
- source: redis:7-alpine
47+
mirror: redis:7-alpine

scripts/generate-image-manifest.test.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { collectImages, renderManifest } from './generate-image-manifest'
2+
import { collectImages, pairSources, renderManifest } from './generate-image-manifest'
33

44
describe('collectImages', () => {
55
it('finds images across every container key a pod spec can use', () => {
@@ -71,25 +71,61 @@ describe('collectImages', () => {
7171
})
7272
})
7373

74+
describe('pairSources', () => {
75+
it('strips the sentinel registry so the mirror path is what the chart resolves to', () => {
76+
const paired = pairSources(
77+
['ghcr.io/simstudioai/simstudio:v1', 'redis:7-alpine'],
78+
['mirror.invalid/simstudioai/simstudio:v1', 'mirror.invalid/redis:7-alpine']
79+
)
80+
81+
// ghcr.io is the default registry and is replaced, so it must not survive.
82+
expect(paired).toEqual([
83+
{ source: 'ghcr.io/simstudioai/simstudio:v1', mirror: 'simstudioai/simstudio:v1' },
84+
{ source: 'redis:7-alpine', mirror: 'redis:7-alpine' },
85+
])
86+
})
87+
88+
it('keeps a registry host that is part of the repository', () => {
89+
const paired = pairSources(
90+
['nvcr.io/nvidia/k8s-device-plugin:v0.18.2'],
91+
['mirror.invalid/nvcr.io/nvidia/k8s-device-plugin:v0.18.2']
92+
)
93+
94+
expect(paired[0].mirror).toBe('nvcr.io/nvidia/k8s-device-plugin:v0.18.2')
95+
})
96+
97+
it('fails rather than emitting a half-paired inventory when the renders disagree', () => {
98+
expect(() => pairSources(['redis:7-alpine'], [])).toThrow(/two renders disagree/)
99+
})
100+
})
101+
74102
describe('renderManifest', () => {
75-
it('renders the app version and a sorted image list', () => {
103+
it('renders the app version and each image as a source/mirror pair', () => {
76104
const manifest = renderManifest({
77105
appVersion: 'v0.8.18',
78-
images: ['busybox:1.36', 'redis:7-alpine'],
106+
images: [{ source: 'ghcr.io/simstudioai/simstudio:v1', mirror: 'simstudioai/simstudio:v1' }],
79107
})
80108

81109
expect(manifest).toContain('appVersion: v0.8.18')
82-
expect(manifest).toContain(' - busybox:1.36\n - redis:7-alpine\n')
110+
expect(manifest).toContain(
111+
' - source: ghcr.io/simstudioai/simstudio:v1\n mirror: simstudioai/simstudio:v1'
112+
)
83113
})
84114

85115
it('omits the chart version so a chart-only bump does not fail the check', () => {
86-
const manifest = renderManifest({ appVersion: 'v1', images: ['a:1'] })
116+
const manifest = renderManifest({
117+
appVersion: 'v1',
118+
images: [{ source: 'a:1', mirror: 'a:1' }],
119+
})
87120

88121
expect(manifest).not.toContain('chartVersion')
89122
})
90123

91124
it('ends with a trailing newline so the checked-in file is POSIX-clean', () => {
92-
const manifest = renderManifest({ appVersion: 'v1', images: ['a:1'] })
125+
const manifest = renderManifest({
126+
appVersion: 'v1',
127+
images: [{ source: 'a:1', mirror: 'a:1' }],
128+
})
93129

94130
expect(manifest.endsWith('\n')).toBe(true)
95131
expect(manifest.endsWith('\n\n')).toBe(false)

scripts/generate-image-manifest.ts

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@ const RENDER_ARGS = [
5050
'ollama.gpu.enabled=true',
5151
] as const
5252

53+
/**
54+
* Sentinel registry used for the second render.
55+
*
56+
* The path an operator must mirror TO is not derivable from the pullable
57+
* reference: `global.imageRegistry` defaults to `ghcr.io`, so a Sim image reads
58+
* `ghcr.io/simstudioai/app` but redirects to `<registry>/simstudioai/app` — the
59+
* `ghcr.io/` is the default registry, not part of the repository. The NVIDIA
60+
* plugin is the reverse: `nvcr.io/` IS in its repository and survives the
61+
* rewrite. Rendering a second time with a known registry and stripping it back
62+
* off yields the real destination for every image without encoding that rule
63+
* twice.
64+
*/
65+
const MIRROR_SENTINEL = 'mirror.invalid'
66+
67+
const MIRROR_RENDER_ARGS = [
68+
...RENDER_ARGS,
69+
'--set',
70+
`global.imageRegistry=${MIRROR_SENTINEL}`,
71+
'--set',
72+
'global.useRegistryForAllImages=true',
73+
] as const
74+
5375
/** Pod-spec keys whose entries carry an image reference. */
5476
const CONTAINER_KEYS = new Set(['containers', 'initContainers', 'ephemeralContainers'])
5577

@@ -87,8 +109,43 @@ export function collectImages(documents: readonly unknown[]): string[] {
87109
}
88110

89111
/** Renders the checked-in manifest. */
90-
export function renderManifest(input: { appVersion: string; images: readonly string[] }): string {
91-
const entries = input.images.map((image) => ` - ${image}`).join('\n')
112+
export interface ImageEntry {
113+
/** The reference to pull from today. */
114+
source: string
115+
/** The path the chart pulls once redirected, relative to your registry. */
116+
mirror: string
117+
}
118+
119+
/**
120+
* Pairs each pullable reference with the repository path the chart resolves to
121+
* under a mirror, by stripping the sentinel registry off the second render.
122+
*/
123+
export function pairSources(sources: readonly string[], mirrored: readonly string[]): ImageEntry[] {
124+
const byTag = new Map<string, string>()
125+
for (const ref of mirrored) {
126+
const stripped = ref.startsWith(`${MIRROR_SENTINEL}/`)
127+
? ref.slice(MIRROR_SENTINEL.length + 1)
128+
: ref
129+
byTag.set(stripped.slice(stripped.lastIndexOf('/') + 1), stripped)
130+
}
131+
132+
return sources.map((source) => {
133+
const key = source.slice(source.lastIndexOf('/') + 1)
134+
const mirror = byTag.get(key)
135+
if (!mirror) {
136+
throw new Error(`No mirrored path rendered for ${source}; the two renders disagree`)
137+
}
138+
return { source, mirror }
139+
})
140+
}
141+
142+
export function renderManifest(input: {
143+
appVersion: string
144+
images: readonly ImageEntry[]
145+
}): string {
146+
const entries = input.images
147+
.map((image) => ` - source: ${image.source}\n mirror: ${image.mirror}`)
148+
.join('\n')
92149

93150
return `# Generated by \`bun run images:generate\`. Do not edit this file directly.
94151
#
@@ -102,12 +159,13 @@ export function renderManifest(input: { appVersion: string; images: readonly str
102159
# their public registries. Pin each image's \`digest\` to what your mirror
103160
# resolved.
104161
#
105-
# Mirror to \`<your-registry>/<reference exactly as listed below>\`. The chart
106-
# prepends your registry to the repository verbatim, so the device plugin lands
107-
# at \`<your-registry>/nvcr.io/nvidia/k8s-device-plugin\` — the \`nvcr.io/\`
108-
# segment is part of the path, not a source host to strip. If your registry
109-
# cannot nest that path, override the one image directly instead:
110-
# \`ollama.gpu.devicePlugin.image.repository=<your-registry>/nvidia/k8s-device-plugin\`.
162+
# Each entry pairs the reference to pull FROM with the path the chart resolves
163+
# to once redirected. Copy \`source\` to \`<your-registry>/<mirror>\` — they differ:
164+
# \`ghcr.io/\` is the default registry and is replaced, while the device plugin's
165+
# \`nvcr.io/\` is part of its repository and is kept. If your registry cannot nest
166+
# that path, override that one image:
167+
# \`ollama.gpu.devicePlugin.image.repository=<your-registry>/nvidia/k8s-device-plugin\`
168+
# with global rewriting left on, since the override is used verbatim.
111169
appVersion: ${input.appVersion}
112170
images:
113171
${entries}
@@ -121,10 +179,10 @@ function readChartField(chart: string, field: string): string {
121179
return match[1].trim()
122180
}
123181

124-
function renderChart(): unknown[] {
182+
function renderChart(args: readonly string[] = RENDER_ARGS): unknown[] {
125183
let rendered: ReturnType<typeof Bun.spawnSync>
126184
try {
127-
rendered = Bun.spawnSync(['helm', ...RENDER_ARGS], { cwd: ROOT })
185+
rendered = Bun.spawnSync(['helm', ...args], { cwd: ROOT })
128186
} catch {
129187
throw new Error('Could not run `helm`. Install the Helm CLI to regenerate the image inventory.')
130188
}
@@ -141,7 +199,10 @@ async function main(): Promise<void> {
141199
const chart = await readFile(resolve(CHART_DIR, 'Chart.yaml'), 'utf8')
142200
const manifest = renderManifest({
143201
appVersion: readChartField(chart, 'appVersion'),
144-
images: collectImages(renderChart()),
202+
images: pairSources(
203+
collectImages(renderChart()),
204+
collectImages(renderChart(MIRROR_RENDER_ARGS))
205+
),
145206
})
146207

147208
if (!CHECK_MODE) {

0 commit comments

Comments
 (0)