Skip to content

Commit 682209d

Browse files
committed
Prepare controlled-session cleanup manifest
1 parent 259b985 commit 682209d

6 files changed

Lines changed: 310 additions & 13 deletions

‎docs/CONTROLLED_SESSION_DESIGN.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,14 @@ summary: Capability-scoped execution sessions that inherit Reploy's global conta
5959
container IDs, their planned ownership identities, the private channel
6060
directory, and the session, lease, and boot identities are now durably
6161
recorded in the existing live-run state. Verified cleanup removes that
62-
record; failed cleanup retains it. The watchdog and restart reconciliation
63-
remain the next ownership phases, and controlled-session networking remains
64-
a later phase.
62+
record; failed cleanup retains it. Reploy now derives the immutable watchdog
63+
cleanup manifest from that recorded ownership before startup. It names only
64+
the exact containers and private channel, carries the host boot identity,
65+
represents the currently absent lease networks and volumes as empty arrays,
66+
and omits protocol authority. The parent and watchdog will be the same
67+
executable, so this internal manifest adds no independent schema-version
68+
marker. Launching the watchdog and restart reconciliation remain the next
69+
ownership phases, and controlled-session networking remains a later phase.
6570
- Initial runtime: Linux containers under Docker
6671
- Motivating clients: OmegaFlow recording, sandboxed AI agents, security
6772
inspection, and untrusted-code execution
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package deploy
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"path/filepath"
9+
10+
"github.com/omry/reploy/internal/canonical"
11+
)
12+
13+
// ControlledSessionCleanupManifest is the complete immutable resource
14+
// selection a session watchdog may receive. It is derived from durable
15+
// ownership rather than accepting later resource choices.
16+
//
17+
// Networks and volumes are explicit arrays even while controlled sessions do
18+
// not create either resource. Future slices may populate them only after their
19+
// exact identities become part of durable ownership.
20+
type ControlledSessionCleanupManifest struct {
21+
LiveRunID string `json:"live_run_id"`
22+
BootSession string `json:"boot_session"`
23+
ChannelDirectory string `json:"channel_directory"`
24+
Controller ControlledSessionContainerOwnershipV1 `json:"controller"`
25+
Workload ControlledSessionContainerOwnershipV1 `json:"workload"`
26+
Networks []string `json:"networks"`
27+
Volumes []string `json:"volumes"`
28+
}
29+
30+
// ControlledSessionCleanupManifestFromOwnership creates the watchdog input
31+
// from the exact durable ownership record. The session handle is deliberately
32+
// omitted because cleanup does not need protocol authority.
33+
func ControlledSessionCleanupManifestFromOwnership(ownership ControlledSessionOwnershipV1) (ControlledSessionCleanupManifest, error) {
34+
if err := validateControlledSessionOwnershipV1(ownership); err != nil {
35+
return ControlledSessionCleanupManifest{}, fmt.Errorf("controlled-session cleanup manifest ownership: %w", err)
36+
}
37+
manifest := ControlledSessionCleanupManifest{
38+
LiveRunID: ownership.LiveRunID, BootSession: ownership.BootSession,
39+
ChannelDirectory: ownership.ChannelDirectory,
40+
Controller: ownership.Controller, Workload: ownership.Workload,
41+
Networks: []string{}, Volumes: []string{},
42+
}
43+
if err := ValidateControlledSessionCleanupManifest(manifest); err != nil {
44+
return ControlledSessionCleanupManifest{}, err
45+
}
46+
return manifest, nil
47+
}
48+
49+
func ValidateControlledSessionCleanupManifest(manifest ControlledSessionCleanupManifest) error {
50+
if err := ValidateLiveRunIDV1(manifest.LiveRunID); err != nil {
51+
return fmt.Errorf("controlled-session cleanup manifest live run ID: %w", err)
52+
}
53+
if err := validateBootSessionIDV1(manifest.BootSession); err != nil {
54+
return fmt.Errorf("controlled-session cleanup manifest: %w", err)
55+
}
56+
if !filepath.IsAbs(manifest.ChannelDirectory) || filepath.Clean(manifest.ChannelDirectory) != manifest.ChannelDirectory || !safeRecoveryIdentity(manifest.ChannelDirectory) {
57+
return fmt.Errorf("controlled-session cleanup manifest channel directory must be a clean absolute path")
58+
}
59+
sessionsDirectory := filepath.Dir(manifest.ChannelDirectory)
60+
if filepath.Base(manifest.ChannelDirectory) != manifest.LiveRunID || filepath.Base(sessionsDirectory) != "sessions" ||
61+
filepath.Base(filepath.Dir(sessionsDirectory)) != ".reploy" {
62+
return fmt.Errorf("controlled-session cleanup manifest channel directory must identify the live-run private session directory")
63+
}
64+
if err := validateControlledSessionContainerOwnershipV1(manifest.Controller, "controller"); err != nil {
65+
return fmt.Errorf("controlled-session cleanup manifest controller: %w", err)
66+
}
67+
if err := validateControlledSessionContainerOwnershipV1(manifest.Workload, "workload"); err != nil {
68+
return fmt.Errorf("controlled-session cleanup manifest workload: %w", err)
69+
}
70+
if manifest.Controller.ID == manifest.Workload.ID {
71+
return fmt.Errorf("controlled-session cleanup manifest containers must be different")
72+
}
73+
if manifest.Networks == nil || manifest.Volumes == nil {
74+
return fmt.Errorf("controlled-session cleanup manifest networks and volumes must use arrays")
75+
}
76+
if err := validateControlledSessionCleanupResourceIDs(manifest.Networks, "network"); err != nil {
77+
return err
78+
}
79+
if err := validateControlledSessionCleanupResourceIDs(manifest.Volumes, "volume"); err != nil {
80+
return err
81+
}
82+
return nil
83+
}
84+
85+
func validateControlledSessionCleanupResourceIDs(resources []string, kind string) error {
86+
for index, resource := range resources {
87+
if !safeRecoveryIdentity(resource) {
88+
return fmt.Errorf("controlled-session cleanup manifest %s identity must be nonempty safe text", kind)
89+
}
90+
if index > 0 && resources[index-1] >= resource {
91+
return fmt.Errorf("controlled-session cleanup manifest %s identities must be sorted and unique", kind)
92+
}
93+
}
94+
return nil
95+
}
96+
97+
func EncodeControlledSessionCleanupManifest(manifest ControlledSessionCleanupManifest) ([]byte, error) {
98+
if err := ValidateControlledSessionCleanupManifest(manifest); err != nil {
99+
return nil, err
100+
}
101+
content, err := canonical.Marshal(manifest)
102+
if err != nil {
103+
return nil, fmt.Errorf("encode controlled-session cleanup manifest: %w", err)
104+
}
105+
return content, nil
106+
}
107+
108+
func DecodeControlledSessionCleanupManifest(content []byte) (ControlledSessionCleanupManifest, error) {
109+
decoder := json.NewDecoder(bytes.NewReader(content))
110+
decoder.DisallowUnknownFields()
111+
var manifest ControlledSessionCleanupManifest
112+
if err := decoder.Decode(&manifest); err != nil {
113+
return ControlledSessionCleanupManifest{}, fmt.Errorf("decode controlled-session cleanup manifest: %w", err)
114+
}
115+
var extra any
116+
if err := decoder.Decode(&extra); err != io.EOF {
117+
if err == nil {
118+
return ControlledSessionCleanupManifest{}, fmt.Errorf("controlled-session cleanup manifest contains trailing JSON")
119+
}
120+
return ControlledSessionCleanupManifest{}, fmt.Errorf("decode controlled-session cleanup manifest trailer: %w", err)
121+
}
122+
if err := ValidateControlledSessionCleanupManifest(manifest); err != nil {
123+
return ControlledSessionCleanupManifest{}, err
124+
}
125+
canonicalContent, err := canonical.Marshal(manifest)
126+
if err != nil {
127+
return ControlledSessionCleanupManifest{}, err
128+
}
129+
if !bytes.Equal(content, canonicalContent) {
130+
return ControlledSessionCleanupManifest{}, fmt.Errorf("controlled-session cleanup manifest is not canonical JSON")
131+
}
132+
return manifest, nil
133+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package deploy
2+
3+
import (
4+
"bytes"
5+
"path/filepath"
6+
"reflect"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestControlledSessionCleanupManifestDerivesExactDurableOwnership(t *testing.T) {
12+
ownership := controlledSessionOwnershipFixtureV1(t.TempDir(), "run-0000000000000001", "reploy/env/workload:g-current")
13+
ownership.BootSession = "boot-session"
14+
manifest, err := ControlledSessionCleanupManifestFromOwnership(ownership)
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
if manifest.LiveRunID != ownership.LiveRunID || manifest.BootSession != ownership.BootSession ||
19+
manifest.ChannelDirectory != ownership.ChannelDirectory || manifest.Controller != ownership.Controller ||
20+
manifest.Workload != ownership.Workload || len(manifest.Networks) != 0 || len(manifest.Volumes) != 0 {
21+
t.Fatalf("cleanup manifest = %#v", manifest)
22+
}
23+
content, err := EncodeControlledSessionCleanupManifest(manifest)
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
if bytes.Contains(content, []byte(ownership.SessionHandle)) || bytes.Contains(content, []byte(`"schema"`)) {
28+
t.Fatalf("cleanup manifest includes unnecessary protocol or version authority: %s", content)
29+
}
30+
decoded, err := DecodeControlledSessionCleanupManifest(content)
31+
if err != nil || !reflect.DeepEqual(decoded, manifest) {
32+
t.Fatalf("decoded manifest = %#v, error=%v", decoded, err)
33+
}
34+
}
35+
36+
func TestControlledSessionCleanupManifestRejectsInvalidDurableOwnership(t *testing.T) {
37+
ownership := controlledSessionOwnershipFixtureV1(t.TempDir(), "run-0000000000000001", "reploy/env/workload:g-current")
38+
ownership.BootSession = "boot-session"
39+
ownership.Workload.ID = ownership.Controller.ID
40+
if _, err := ControlledSessionCleanupManifestFromOwnership(ownership); err == nil || !strings.Contains(err.Error(), "different containers") {
41+
t.Fatalf("invalid durable ownership error = %v", err)
42+
}
43+
}
44+
45+
func TestControlledSessionCleanupManifestRequiresExactChannelAndCanonicalArrays(t *testing.T) {
46+
ownership := controlledSessionOwnershipFixtureV1(t.TempDir(), "run-0000000000000001", "reploy/env/workload:g-current")
47+
ownership.BootSession = "boot-session"
48+
manifest, err := ControlledSessionCleanupManifestFromOwnership(ownership)
49+
if err != nil {
50+
t.Fatal(err)
51+
}
52+
manifest.ChannelDirectory = filepath.VolumeName(ownership.ChannelDirectory) + string(filepath.Separator)
53+
if err := ValidateControlledSessionCleanupManifest(manifest); err == nil || !strings.Contains(err.Error(), "private session directory") {
54+
t.Fatalf("broad channel directory error = %v", err)
55+
}
56+
manifest.ChannelDirectory = ownership.ChannelDirectory
57+
manifest.Networks = nil
58+
if err := ValidateControlledSessionCleanupManifest(manifest); err == nil || !strings.Contains(err.Error(), "must use arrays") {
59+
t.Fatalf("nil resources error = %v", err)
60+
}
61+
manifest.Networks = []string{"network-b", "network-a"}
62+
if err := ValidateControlledSessionCleanupManifest(manifest); err == nil || !strings.Contains(err.Error(), "sorted and unique") {
63+
t.Fatalf("unordered resources error = %v", err)
64+
}
65+
}
66+
67+
func TestDecodeControlledSessionCleanupManifestRejectsUnknownAndNoncanonicalJSON(t *testing.T) {
68+
ownership := controlledSessionOwnershipFixtureV1(t.TempDir(), "run-0000000000000001", "reploy/env/workload:g-current")
69+
ownership.BootSession = "boot-session"
70+
manifest, err := ControlledSessionCleanupManifestFromOwnership(ownership)
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
content, err := EncodeControlledSessionCleanupManifest(manifest)
75+
if err != nil {
76+
t.Fatal(err)
77+
}
78+
unknown := append(append([]byte{}, content[:len(content)-1]...), []byte(`,"extra":true}`)...)
79+
if _, err := DecodeControlledSessionCleanupManifest(unknown); err == nil || !strings.Contains(err.Error(), "unknown field") {
80+
t.Fatalf("unknown field error = %v", err)
81+
}
82+
pretty := append([]byte("\n"), content...)
83+
if _, err := DecodeControlledSessionCleanupManifest(pretty); err == nil || !strings.Contains(err.Error(), "not canonical") {
84+
t.Fatalf("noncanonical error = %v", err)
85+
}
86+
}

‎internal/deploy/live_run_queue_file_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func controlledSessionOwnershipFixtureV1(dir string, runID string, generation st
174174
}
175175
return ControlledSessionOwnershipV1{
176176
LiveRunID: runID, SessionHandle: "session-" + strings.Repeat("a", 64),
177-
ChannelDirectory: filepath.Join(dir, ".reploy", "private", "sessions", runID),
177+
ChannelDirectory: filepath.Join(dir, ".reploy", "sessions", runID),
178178
Controller: container("controller", strings.Repeat("a", 64), "controller", "reploy/env/controller:g-current", "1"),
179179
Workload: container("workload", strings.Repeat("b", 64), "workload", generation, "2"),
180180
}

‎internal/dockerdeploy/controlled_session_supervisor.go‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ type controlledSessionSupervisorBackendV1 struct {
7878
prepareChannel func(ControlledSessionExecutionPlanV1) (controlledSessionChannelRuntimeV1, error)
7979
prepareController func(context.Context, ControlledSessionContainerPlanV1) (controlledSessionControllerRuntimeV1, error)
8080
prepareWorkload func(context.Context, ControlledSessionContainerPlanV1) (controlledSessionWorkloadRuntimeV1, error)
81-
recordOwnership func(string, string) error
81+
recordOwnership func(string, string) (deploy.ControlledSessionCleanupManifest, error)
8282
now func() time.Time
8383
}
8484

@@ -120,6 +120,7 @@ type controlledSessionSupervisorV1 struct {
120120
workloadRecorded bool
121121
controllerStarted bool
122122
workloadStarted bool
123+
cleanupManifest deploy.ControlledSessionCleanupManifest
123124

124125
transportHealthy bool
125126
diagnosticErr error
@@ -175,16 +176,21 @@ func RunControlledSessionV1(
175176
prepareWorkload: func(ctx context.Context, plan ControlledSessionContainerPlanV1) (controlledSessionWorkloadRuntimeV1, error) {
176177
return PrepareDockerWorkloadPTYV1(ctx, plan)
177178
},
178-
recordOwnership: func(controllerID string, workloadID string) error {
179+
recordOwnership: func(controllerID string, workloadID string) (deploy.ControlledSessionCleanupManifest, error) {
179180
ownership := controlledSessionOwnershipFromPlanV1(plan, controllerID, workloadID)
180-
if _, err := operation.RecordControlledSessionOwnershipV1(ownership); err != nil {
181-
return fmt.Errorf("persist controlled-session ownership: %w", err)
181+
recorded, err := operation.RecordControlledSessionOwnershipV1(ownership)
182+
if err != nil {
183+
return deploy.ControlledSessionCleanupManifest{}, fmt.Errorf("persist controlled-session ownership: %w", err)
184+
}
185+
manifest, err := deploy.ControlledSessionCleanupManifestFromOwnership(recorded)
186+
if err != nil {
187+
return deploy.ControlledSessionCleanupManifest{}, fmt.Errorf("prepare controlled-session cleanup manifest: %w", err)
182188
}
183189
released = true
184190
if err := operation.Unlock(); err != nil {
185-
return fmt.Errorf("release operation lock before controlled-session startup: %w", err)
191+
return deploy.ControlledSessionCleanupManifest{}, fmt.Errorf("release operation lock before controlled-session startup: %w", err)
186192
}
187-
return nil
193+
return manifest, nil
188194
},
189195
now: time.Now,
190196
})
@@ -368,9 +374,16 @@ func (supervisor *controlledSessionSupervisorV1) prepare(ctx context.Context) er
368374
return fmt.Errorf("claim controlled-session workload output: %w", err)
369375
}
370376
if supervisor.backend.recordOwnership != nil {
371-
if err := supervisor.backend.recordOwnership(controller.ContainerID(), workload.ContainerID()); err != nil {
377+
manifest, err := supervisor.backend.recordOwnership(controller.ContainerID(), workload.ContainerID())
378+
if err != nil {
372379
return err
373380
}
381+
if err := validateControlledSessionCleanupManifestForRuntimeV1(
382+
manifest, supervisor.plan, controller.ContainerID(), workload.ContainerID(),
383+
); err != nil {
384+
return err
385+
}
386+
supervisor.cleanupManifest = manifest
374387
}
375388
if err := controller.Start(ctx); err != nil {
376389
return fmt.Errorf("start controlled-session controller: %w", err)
@@ -398,6 +411,26 @@ func (supervisor *controlledSessionSupervisorV1) prepare(ctx context.Context) er
398411
return nil
399412
}
400413

414+
func validateControlledSessionCleanupManifestForRuntimeV1(
415+
manifest deploy.ControlledSessionCleanupManifest,
416+
plan ControlledSessionExecutionPlanV1,
417+
controllerID string,
418+
workloadID string,
419+
) error {
420+
if err := deploy.ValidateControlledSessionCleanupManifest(manifest); err != nil {
421+
return fmt.Errorf("validate controlled-session cleanup manifest: %w", err)
422+
}
423+
expected := controlledSessionOwnershipFromPlanV1(plan, controllerID, workloadID)
424+
if manifest.LiveRunID != expected.LiveRunID || manifest.ChannelDirectory != expected.ChannelDirectory ||
425+
manifest.Controller != expected.Controller || manifest.Workload != expected.Workload {
426+
return fmt.Errorf("controlled-session cleanup manifest does not match the exact prepared resources")
427+
}
428+
if len(manifest.Networks) != 0 || len(manifest.Volumes) != 0 {
429+
return fmt.Errorf("controlled-session cleanup manifest names resources that this runtime does not create")
430+
}
431+
return nil
432+
}
433+
401434
func observeControlledSessionProcessV1(
402435
wait func(context.Context) (controlledsession.ProcessStatusV1, error),
403436
) <-chan controlledSessionProcessResultV1 {

0 commit comments

Comments
 (0)