|
| 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 | +} |
0 commit comments