Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/ENV_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ source.
| `SSM_RELOAD_LOG_LEVEL` | | `INFO` | Log verbosity for the reloader's structured logs. |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | | — | OTLP/HTTP endpoint to opt in to OpenTelemetry event export; unset disables telemetry entirely. |
| `SSM_RELOAD_PROJECTION_CONFIGS` | | (empty) | Comma-separated `project/config` pairs to project even when no container is bound to them yet — an `env_file` must exist BEFORE the first `compose up` that reads it. |
| `SSM_RELOAD_SWARM_MODE` | | `False` | Manage Docker Swarm services instead of local containers: deliver each config as a Swarm secret/config object and roll it out with `docker service update` on change. Requires the local Docker socket to belong to a swarm MANAGER node; run a single instance of ssm-reload per swarm, not one per node. |
| `SSM_RELOAD_SWARM_SECRET_KIND` | | `secret` | Swarm object kind used to deliver secrets when SSM_RELOAD_SWARM_MODE is set: `secret` (encrypted at rest, the default) or `config` (unencrypted). Ignored outside swarm mode. |
| `SSM_RELOAD_SWARM_CONFIG_MOUNT_DIR` | | `/run/ssm` | Absolute path a Swarm `config` object is mounted under when SSM_RELOAD_SWARM_SECRET_KIND=config; ignored for the `secret` kind, whose mount directory Swarm itself fixes at `/run/secrets`. Ignored outside swarm mode. |
| `DOCKER_HOST` | | local Docker socket | Standard Docker endpoint override, honored directly by the Docker SDK's `docker.from_env()` — not a `ReloadSettings` field; `ssm-reload` never reads or validates it itself. |
94 changes: 92 additions & 2 deletions docs/SECRETS_RELOADER.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,95 @@ is a no-op with zero cost. Events carry semantic-convention attributes

---

## Swarm mode

The quick start above delivers secrets via a bind-mounted volume, which only
works on a **single Docker host**. Docker Swarm services can run their tasks
on any node in the cluster, so set `SSM_RELOAD_SWARM_MODE=true` to switch
delivery to **Docker Swarm secrets or configs** instead — cluster-wide objects
Swarm itself replicates to whichever node a task lands on.

### What changes

| | Default (non-swarm) mode | Swarm mode |
| --- | --- | --- |
| Unit managed | A container | A Swarm **service** |
| Opt-in label lives on | The container | The service's `deploy.labels` OR its task template's `labels:` — ssm-reload checks both |
| Delivery mechanism | A dotenv file in a shared bind-mounted volume | A Docker **secret** or **config** object, attached to the service |
| How secrets reach the process | `env_file:` — literal environment variables | A mounted FILE the process must read itself (there is no Swarm-native way to turn a secret into a literal env var) |
| Mount path | `/run/ssm/<project>-<config>.env` | `/run/secrets/<project>-<config>.env` for the `secret` kind (fixed by Swarm itself); `SSM_RELOAD_SWARM_CONFIG_MOUNT_DIR` (default `/run/ssm`) for the `config` kind |
| Rollout mechanism | Recreate the container | `docker service update`, which Swarm rolls out to every task across the cluster using the service's own update policy |
| Where it must run | One instance per Docker host | Exactly **one instance for the whole swarm**, on a **manager** node |

### Will it reload a running Swarm service when a secret changes?

**Yes, automatically** — `ssm-reload` mints a new secret/config object per
revision and calls `docker service update`, and Swarm itself rolls that out to
every task of the service, on every node, following the service's own update
policy. No manual `docker service update` is required.

The one condition that must hold for this to actually reach the running
process: **the image must read its secrets from the mounted file**, because
Swarm has no mechanism to merge a secret's bytes into a service's literal
environment the way a recreated container's env can be merged. Source the
file in the entrypoint (`. /run/secrets/web-prod.env`) or use an image that
already reads a `*_FILE`/dotenv-style secret. **An image that hard-requires
literal environment variables, and cannot be given an entrypoint wrapper,
cannot pick up a rotated secret automatically in swarm mode — it must be
restarted by hand after a rotation**, or kept on the default (non-swarm) mode
instead.

### Requirements and constraints

- **Manager-only, single instance.** Creating secrets/configs and updating a
service both require a swarm manager's Docker API. Point `ssm-reload`'s
socket at a manager node and run exactly one replica — two instances would
race each other minting differently-named objects for the same revision.
Pin it with `deploy.placement.constraints: [node.role == manager]`.
- **`SSM_RELOAD_SWARM_SECRET_KIND`** selects `secret` (encrypted at rest — the
default) or `config` (plain).
- **`SSM_RELOAD_SWARM_CONFIG_MOUNT_DIR`** (default `/run/ssm`) selects where a
`config`-kind object is mounted; ignored for the `secret` kind, whose
mount directory Swarm itself fixes at `/run/secrets`.
- **Old objects are garbage-collected automatically.** Docker refuses to
delete a secret still bound to a task, so `ssm-reload` recomputes "is
anything still using this?" from live cluster state every pass and removes
what it minted once nothing does — no durable bookkeeping of its own.
- **First boot is a real, structural gap.** A service's very first
`docker stack deploy` starts before `ssm-reload` has ever discovered it, so
no secret is attached yet; an image that hard-requires the file will
crash-loop until the next poll attaches it and rolls the service. Unlike the
default mode's `SSM_RELOAD_PROJECTION_CONFIGS` bootstrap list, there is no
way to pre-attach a secret to a service that does not exist yet.
What you CAN do: pre-create a placeholder secret and reference it
statically in the service's own `secrets:` block, at the same target
ssm-reload will use. Its first successful pass replaces that reference
with its own live one at the SAME target — matched by mount path, not by
name — taking it over rather than leaving both attached (which Swarm
would reject as a duplicate mount anyway). For example:

```yaml
secrets:
test-prod.env:
external: true

services:
web:
secrets:
- source: test-prod.env
target: web-prod.env # matches env_filename(project, config)
```

Create the placeholder once (`printf 'PLACEHOLDER="set me"\n' | docker secret
create test-prod.env -`), then `docker stack deploy`. Skip this entirely if
the image can tolerate crash-looping for one poll interval on a true first
deploy.

A complete, runnable example lives at
[`../ssm_reload/docker-stack.swarm.example.yml`](../ssm_reload/docker-stack.swarm.example.yml).

---

## Run it anywhere, at any scale

`ssm-reload` is stateless and isolated — it depends only on the SSM API and
Expand Down Expand Up @@ -451,8 +540,9 @@ conflict and need no coordination.

- **Docker only.** Kubernetes support is designed for and can be added
behind the same interface later; today `ssm-reload` manages Docker
containers. The projection sink is a seam too — a Kubernetes Secret slots in
behind the same renderer.
containers, plus Docker Swarm services in [swarm mode](#swarm-mode). The
projection sink is a seam too — a Kubernetes Secret slots in behind the
same renderer.
- **Pull-based.** `ssm-reload` polls and watches Docker events; it does not
require the SSM server to push to it.
- **The tmpfs volume is empty after a reboot.** The reloader re-renders every
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/api/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ export async function updateMe(input: { email?: string; fullName?: string }): Pr
});
return mapMeResponseDto(meResponseDtoSchema.parse(response));
}

export async function changeMyPassword(input: { currentPassword: string; newPassword: string }): Promise<void> {
await apiClient<unknown>('/me/password', {
method: 'PATCH',
body: JSON.stringify(input)
});
}
7 changes: 7 additions & 0 deletions frontend/src/lib/api/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export async function updateWorkspaceMember(
fullName: string;
workspaceRole: WorkspaceMember['workspaceRole'];
disabled: boolean;
password: string;
}>
) {
await apiClient<WorkspaceMembersResponseDto>(`/workspace/members/${username}`, {
Expand All @@ -70,6 +71,12 @@ export async function updateWorkspaceMember(
});
}

export async function deleteWorkspaceMember(username: string) {
await apiClient<{ status?: string }>(`/workspace/members/${username}`, {
method: 'DELETE'
});
}

export async function getProjectMembers(projectSlug: string): Promise<WorkspaceProjectMember[]> {
const response = await apiClient<WorkspaceProjectMembersResponseDto>(
`/workspace/projects/${projectSlug}/members`
Expand Down
47 changes: 45 additions & 2 deletions frontend/src/pages/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { getMe, updateMe } from '../lib/api/me';
import { changeMyPassword, getMe, updateMe } from '../lib/api/me';
import { queryKeys } from '../lib/api/queryKeys';
import { notifyApiError } from '../lib/api/errorToast';

Expand All @@ -17,6 +17,8 @@ export function AccountPage() {

const [email, setEmail] = useState('');
const [fullName, setFullName] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');

useEffect(() => {
if (!me) return;
Expand All @@ -35,8 +37,20 @@ export function AccountPage() {
}
});

const changePasswordMutation = useMutation({
mutationFn: changeMyPassword,
onSuccess: () => {
setCurrentPassword('');
setNewPassword('');
toast.success('Password changed');
},
onError: (error) => {
notifyApiError(error, 'Failed to change password');
}
});

return (
<div className="p-6 max-w-3xl">
<div className="p-6 max-w-3xl space-y-4">
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
Expand Down Expand Up @@ -80,6 +94,35 @@ export function AccountPage() {
)}
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>Update your login password.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<label className="text-xs text-muted-foreground">Current Password</label>
<Input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</div>
<div className="grid gap-2">
<label className="text-xs text-muted-foreground">New Password</label>
<Input type="password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} />
</div>
<div className="flex justify-end">
<Button
disabled={changePasswordMutation.isPending || !currentPassword || !newPassword}
onClick={() => changePasswordMutation.mutate({ currentPassword, newPassword })}
>
Change Password
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
140 changes: 140 additions & 0 deletions frontend/src/pages/TeamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
Expand All @@ -14,6 +23,7 @@ import {
} from '@/components/ui/select';
import {
createWorkspaceMember,
deleteWorkspaceMember,
getWorkspaceMembers,
getWorkspaceSettings,
updateWorkspaceMember,
Expand All @@ -27,6 +37,102 @@ import { notifyApiError } from '../lib/api/errorToast';
const WORKSPACE_ROLES: WorkspaceMember['workspaceRole'][] = ['owner', 'admin', 'collaborator', 'viewer'];
const PROJECT_ROLES: WorkspaceSettings['defaultProjectRole'][] = ['admin', 'collaborator', 'viewer', 'none'];

function ResetPasswordDialog({
username,
onSubmit,
isPending
}: {
username: string;
onSubmit: (password: string) => void;
isPending: boolean;
}) {
const [open, setOpen] = useState(false);
const [password, setPassword] = useState('');

return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setPassword('');
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Reset Password
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset password for {username}</DialogTitle>
<DialogDescription>Sets a new password for this member immediately.</DialogDescription>
</DialogHeader>
<Input
type="password"
placeholder="New password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<DialogFooter>
<Button
disabled={isPending || !password}
onClick={() => {
onSubmit(password);
setOpen(false);
setPassword('');
}}
>
Reset Password
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

function DeleteMemberDialog({
username,
onConfirm,
isPending
}: {
username: string;
onConfirm: () => void;
isPending: boolean;
}) {
const [open, setOpen] = useState(false);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {username}?</DialogTitle>
<DialogDescription>
This permanently removes the user, their credentials, and all workspace/project/group memberships. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="destructive"
disabled={isPending}
onClick={() => {
onConfirm();
setOpen(false);
}}
>
Delete Member
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

export function TeamPage() {
const queryClient = useQueryClient();
const { data: me } = useQuery({ queryKey: queryKeys.me(), queryFn: getMe });
Expand Down Expand Up @@ -79,6 +185,24 @@ export function TeamPage() {
onError: (error) => notifyApiError(error, 'Failed to update member status')
});

const resetPasswordMutation = useMutation({
mutationFn: ({ username, password }: { username: string; password: string }) =>
updateWorkspaceMember(username, { password }),
onSuccess: () => {
toast.success('Password reset');
},
onError: (error) => notifyApiError(error, 'Failed to reset password')
});

const deleteMemberMutation = useMutation({
mutationFn: deleteWorkspaceMember,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.workspaceMembers() });
toast.success('Member deleted');
},
onError: (error) => notifyApiError(error, 'Failed to delete member')
});

const updateSettingsMutation = useMutation({
mutationFn: updateWorkspaceSettings,
onSuccess: () => {
Expand Down Expand Up @@ -194,6 +318,22 @@ export function TeamPage() {
>
{member.disabled ? 'Disabled (click to enable)' : 'Enabled (click to disable)'}
</Button>
{canManageMembers && (
<ResetPasswordDialog
username={member.username}
isPending={resetPasswordMutation.isPending}
onSubmit={(password) =>
resetPasswordMutation.mutate({ username: member.username, password })
}
/>
)}
{canManageMembers && member.username !== me?.username && (
<DeleteMemberDialog
username={member.username}
isPending={deleteMemberMutation.isPending}
onConfirm={() => deleteMemberMutation.mutate(member.username)}
/>
)}
</div>
</div>
))}
Expand Down
Loading