summaryrefslogtreecommitdiff
path: root/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs
blob: dbf87df4abc8fad3193b151e02b8ba4861799df4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
namespace SomhairlesDream.Server

open System
open System.IO
open System.Threading.Tasks
open SomhairlesDream.Modeling
open SomhairlesDream.Shared

type RunStartOutcome =
    | Accepted of RunSnapshot
    | Conflict of RunSnapshot

type ArtifactLookupError =
    | RunNotFound
    | RunNotComplete of RunSnapshot
    | InvalidArtifactPath of string
    | ArtifactNotFound
    | InvalidManifest of string

type ArtifactRunCoordinator(
    artifactRoot: string,
    bridgeFactory: unit -> IArtifactBridge,
    clock: unit -> DateTimeOffset,
    staleAfter: TimeSpan
) =
    let root = Path.GetFullPath(artifactRoot)
    let registry = ArtifactRunRegistry(staleAfter, clock)

    let validateIds (ids: RunIds) =
        [| ids.ProjectId; ids.RunId; ids.BaseId; ids.TargetId |]
        |> Array.tryFind (Ids.validateResourceId >> Result.isError)
        |> function
            | Some invalid -> Error $"invalid resource id: {invalid}"
            | None -> Ok ()

    let runDirectory (projectId: string) (runId: string) =
        Path.Combine(root, projectId, runId)

    let failIfNeeded (store: ArtifactRunStore) (ids: RunIds) message =
        let snapshot = store.Snapshot()

        if snapshot.Status = "idle" || snapshot.Status = "running" then
            store.Apply(
                RunEvent.Fail
                    { Ids = ids
                      At = clock ()
                      StepId = snapshot.CurrentStepId
                      Message = message }
            )
            |> ignore

    let runPipeline (store: ArtifactRunStore) (ids: RunIds) render =
        try
            let options : PipelineOptions =
                { ArtifactRoot = root
                  Ids = ids
                  Render = render
                  Bridge = bridgeFactory ()
                  Clock = clock
                  OnEvent =
                      fun event ->
                          match store.Apply event with
                          | Ok _ -> ()
                          | Error message -> invalidOp message }

            match Pipeline.run options with
            | Ok _ -> ()
            | Error message -> failIfNeeded store ids message
        with ex ->
            failIfNeeded store ids ex.Message

    let safeArtifactPath (runDirectory: string) (relativePath: string) =
        if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then
            Error "artifact path must be relative"
        else
            let normalized = relativePath.Replace('\\', '/')
            let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries)

            if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then
                Error "artifact path contains traversal"
            else
                let fullPath = Path.GetFullPath(Path.Combine(runDirectory, normalized.Replace('/', Path.DirectorySeparatorChar)))
                let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar

                if fullPath.StartsWith(prefix, StringComparison.Ordinal) then
                    Ok fullPath
                else
                    Error "artifact path escapes run directory"

    member _.ArtifactRoot = root

    member _.Start(ids: RunIds, render: bool) : Result<RunStartOutcome, string> =
        match validateIds ids with
        | Error message -> Error message
        | Ok () ->
            match registry.TryCreate(ids) with
            | Some store ->
                Task.Run(fun () -> runPipeline store ids render) |> ignore
                Ok(Accepted(store.Snapshot()))
            | None ->
                match registry.TryFind(ids.ProjectId, ids.RunId) with
                | Some store -> Ok(Conflict(store.Observe(clock ())))
                | None -> Error "run registry entry disappeared"

    member _.TryFind(projectId: string, runId: string) = registry.TryFind(projectId, runId)

    member _.Subscribe(projectId: string, runId: string) =
        registry.TryFind(projectId, runId) |> Option.map (fun store -> store.Subscribe())

    member _.ObserveAll() = registry.ObserveAll(clock ())

    member _.Manifest(projectId: string, runId: string) : Result<ArtifactManifest, ArtifactLookupError> =
        match registry.TryFind(projectId, runId) with
        | None -> Error RunNotFound
        | Some store ->
            let snapshot = store.Observe(clock ())

            if snapshot.Status <> "complete" then
                Error(RunNotComplete snapshot)
            else
                let path = Path.Combine(runDirectory projectId runId, "manifest.json")

                match ArtifactVerifier.verify path with
                | Ok manifest -> Ok manifest
                | Error message -> Error(InvalidManifest message)

    member _.Artifact(projectId: string, runId: string, relativePath: string) : Result<string, ArtifactLookupError> =
        match registry.TryFind(projectId, runId) with
        | None -> Error RunNotFound
        | Some store ->
            let snapshot = store.Observe(clock ())

            if snapshot.Status <> "complete" then
                Error(RunNotComplete snapshot)
            else
                let directory = runDirectory projectId runId

                match safeArtifactPath directory relativePath with
                | Error message -> Error(InvalidArtifactPath message)
                | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound
                | Ok path -> Ok path

    member _.StepArtifact(projectId: string, runId: string, relativePath: string) : Result<string, ArtifactLookupError> =
        match registry.TryFind(projectId, runId) with
        | None -> Error RunNotFound
        | Some _ ->
            let directory = runDirectory projectId runId

            match safeArtifactPath directory relativePath with
            | Error message -> Error(InvalidArtifactPath message)
            | Ok path when not (path.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)) ->
                Error(InvalidArtifactPath "step artifacts must be .glb files")
            | Ok path when not (path.StartsWith(Path.Combine(directory, "steps") + string Path.DirectorySeparatorChar, StringComparison.Ordinal)) ->
                Error(InvalidArtifactPath "step artifacts must live under steps/")
            | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound
            | Ok path -> Ok path