feat: default steps are read only
Server Deploy / deploy (push) Successful in 1m46s

This commit is contained in:
2026-07-28 13:34:08 +01:00
parent 26567766a9
commit 31e0000306
7 changed files with 80 additions and 23 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Monitors
+9
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
@@ -185,6 +186,10 @@ func updateStep(c *gin.Context) {
return
}
if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil {
if errors.Is(err, services.ErrDefaultStep) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -194,6 +199,10 @@ func updateStep(c *gin.Context) {
func deleteStep(c *gin.Context) {
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
if errors.Is(err, services.ErrDefaultStep) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+29
View File
@@ -2,6 +2,7 @@ package services
import (
"context"
"errors"
"fmt"
"time"
@@ -13,6 +14,24 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrDefaultStep is returned when a caller tries to edit or delete a step that
// came from the image's default library. Those rows are re-seeded from disk on
// every boot, so an edit would be silently reverted and a delete would come
// back — refusing is honest about who owns them.
var ErrDefaultStep = errors.New("this step ships with Vantage and cannot be edited or deleted; duplicate it to make your own copy")
func isDefaultStep(ctx context.Context, instanceID, stepID string) (bool, error) {
var s models.WorkflowStep
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}).Decode(&s)
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if err != nil {
return false, err
}
return s.Source == "default", nil
}
func wfCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
@@ -115,6 +134,11 @@ func CreateStep(instanceID string, s models.WorkflowStep) (*models.WorkflowStep,
func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error {
ctx, cancel := wfCtx()
defer cancel()
if def, err := isDefaultStep(ctx, instanceID, stepID); err != nil {
return err
} else if def {
return ErrDefaultStep
}
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}, bson.M{"$set": bson.M{
"name": s.Name,
"description": s.Description,
@@ -131,6 +155,11 @@ func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error {
func DeleteStep(instanceID, stepID string) error {
ctx, cancel := wfCtx()
defer cancel()
if def, err := isDefaultStep(ctx, instanceID, stepID); err != nil {
return err
} else if def {
return ErrDefaultStep
}
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}); err != nil {
return err
}
+6 -4
View File
@@ -175,14 +175,16 @@ export default function StepsPage() {
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-3 text-text-secondary">
<button onClick={() => openEdit(s)} className="hover:text-text-primary">
Edit
{s.source === "default" ? "View" : "Edit"}
</button>
<a href={api.exportStepUrl(s.step_id)} download className="hover:text-text-primary">
Export
</a>
<button onClick={() => openEdit(s)} className="hover:text-danger">
Delete
</button>
{s.source !== "default" && (
<button onClick={() => openEdit(s)} className="hover:text-danger">
Delete
</button>
)}
</div>
</td>
</tr>
+9 -2
View File
@@ -457,7 +457,8 @@ export default function WorkflowBuilder() {
<div className="border-b border-border pb-4">
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
<textarea
className={`${inputClass} h-32 font-mono text-xs`}
className={`${inputClass} h-32 font-mono text-xs ${selectedLib?.source === "default" ? "cursor-not-allowed opacity-70" : ""}`}
readOnly={selectedLib?.source === "default"}
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
onChange={(e) =>
updateRef(selectedIdxInWf, {
@@ -466,7 +467,13 @@ export default function WorkflowBuilder() {
}
/>
<p className="mt-1 text-xs text-text-secondary">
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.
{selectedLib?.source === "default" ? (
<>This step ships with Vantage, so its script is fixed. Set its inputs below, or duplicate it into a shared step to change the script.</>
) : (
<>
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.
</>
)}
</p>
</div>
)}
+25 -15
View File
@@ -17,8 +17,10 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Default steps are re-seeded from the image on every boot, so the server
// refuses to update or delete them. The form mirrors that rather than
// offering buttons that can only 409.
const locked = step?.source === "default";
const save = async () => {
setBusy(true); setError(null);
@@ -47,24 +49,30 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
};
return (
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
<Modal open={open} onClose={onClose} title={locked ? "Default step" : step ? "Edit base step" : "New step"} wide>
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
{locked ? (
<p className="rounded border border-border bg-surface-2 px-3 py-2 text-xs text-text-secondary">
This step ships with Vantage and is read-only. It is re-seeded on every upgrade. Export it and import a copy to make your own version.
</p>
) : (
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
)}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
<input className={inputClass} value={name} readOnly={locked} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
<select className={inputClass} value={interpreter} disabled={locked} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
<option value="bash">bash</option>
<option value="powershell">powershell</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} readOnly={locked} onChange={(e) => setScript(e.target.value)} />
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
</div>
<div>
@@ -86,20 +94,22 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
<div className="space-y-2">
{inputs.map((inp, i) => (
<div key={i} className="flex gap-2">
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}></Button>
<input className={inputClass} readOnly={locked} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
<input className={inputClass} readOnly={locked} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
<input className={inputClass} readOnly={locked} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
{!locked && <Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}></Button>}
</div>
))}
</div>
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
{!locked && (
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
)}
</div>
<div className="flex items-center justify-between pt-2">
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
{step && !locked ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
<Button variant="ghost" onClick={onClose}>{locked ? "Close" : "Cancel"}</Button>
{!locked && <Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>}
</div>
</div>
</div>
File diff suppressed because one or more lines are too long