diff --git a/Dockerfile.dev b/Dockerfile.dev index 3926dcb..ace4c66 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -5,7 +5,11 @@ COPY . /go WORKDIR /go RUN CGO_ENABLED=0 go build -ldflags "-X github.com/highcard-dev/daemon/internal.Version=${VERSION}" -o /out/druid-dev ./apps/druid-dev -FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git python3 make g++ vim && rm -rf /var/lib/apt/lists/* +FROM node:24-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git python3 make g++ vim && rm -rf /var/lib/apt/lists/* +ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo +ENV PATH=/usr/local/cargo/bin:$PATH +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \ + && rustup target add wasm32-unknown-unknown COPY --from=builder /out/druid-dev /usr/local/bin/druid-dev ENTRYPOINT ["druid-dev"] diff --git a/apps/druid/core/services/runtime_supervisor_test.go b/apps/druid/core/services/runtime_supervisor_test.go index 59a881c..26a509d 100644 --- a/apps/druid/core/services/runtime_supervisor_test.go +++ b/apps/druid/core/services/runtime_supervisor_test.go @@ -1678,7 +1678,7 @@ func (f *fakeWorkerBackend) RunCommand(command ports.RuntimeCommand) (*int, erro } func (f *fakeWorkerBackend) CreateUIPackageUpload(ctx context.Context, action ports.RuntimeUIPackageUploadAction) (ports.RuntimeUIPackageUpload, error) { - return ports.RuntimeUIPackageUpload{UploadURL: "http://uploads/" + action.RuntimeID + "/" + string(action.Scope), URL: "http://packages/" + action.RuntimeID + "/" + string(action.Scope) + "/app.wasm"}, nil + return ports.RuntimeUIPackageUpload{UploadURL: "http://uploads/" + action.RuntimeID + "/" + string(action.Scope), DocumentUploadURL: "http://uploads/" + action.RuntimeID + "/" + string(action.Scope) + "/app.openui", URL: "http://packages/" + action.RuntimeID + "/" + string(action.Scope) + "/app.wasm"}, nil } func (f *fakeWorkerBackend) ExpectedPorts(root string, commands map[string]*domain.CommandInstructionSet, ports []domain.Port) ([]domain.RuntimePortStatus, error) { diff --git a/apps/druid/core/services/runtime_ui.go b/apps/druid/core/services/runtime_ui.go index e54af4f..6c334b3 100644 --- a/apps/druid/core/services/runtime_ui.go +++ b/apps/druid/core/services/runtime_ui.go @@ -39,7 +39,7 @@ func (s *RuntimeSupervisor) PublishUIPackage(id string, scope string, sourcePath if err != nil { return nil, fmt.Errorf("create UI package upload: %w", err) } - if upload.UploadURL == "" || upload.URL == "" { + if upload.UploadURL == "" || upload.DocumentUploadURL == "" || upload.URL == "" { return nil, fmt.Errorf("UI package upload is incomplete") } @@ -56,8 +56,10 @@ func (s *RuntimeSupervisor) PublishUIPackage(id string, scope string, sourcePath procedureName := domain.ProcedureName(commandName, 0, command.Procedures[0]) if err := session.AddTempItemWithWaitEnv(commandName, map[string]map[string]string{ procedureName: { - "DRUID_UI_SOURCE": sourcePath, - "DRUID_UI_UPLOAD_URL": upload.UploadURL, + "DRUID_UI_SOURCE": sourcePath, + "DRUID_UI_DOCUMENT_SOURCE": strings.TrimSuffix(sourcePath, ".wasm") + ".openui", + "DRUID_UI_UPLOAD_URL": upload.UploadURL, + "DRUID_UI_DOCUMENT_UPLOAD_URL": upload.DocumentUploadURL, }, }); err != nil { return nil, err @@ -86,10 +88,20 @@ func uiPublishCommand() *domain.CommandInstructionSet { WorkingDir: "/app/resources/deployment", Mounts: []domain.Mount{{Path: "/app/resources/deployment", SubPath: ".", ReadOnly: true}}, Command: []string{"sh", "-ec", ` -test -n "$(printenv DRUID_UI_SOURCE)" && test -n "$(printenv DRUID_UI_UPLOAD_URL)" +test -n "$(printenv DRUID_UI_SOURCE)" +test -n "$(printenv DRUID_UI_DOCUMENT_SOURCE)" +test -n "$(printenv DRUID_UI_UPLOAD_URL)" +test -n "$(printenv DRUID_UI_DOCUMENT_UPLOAD_URL)" case "$(printenv DRUID_UI_SOURCE)" in private/*.wasm|public/*.wasm) ;; *) echo "invalid UI package path" >&2; exit 1;; esac -test -f "$(printenv DRUID_UI_SOURCE)" && test -s "$(printenv DRUID_UI_SOURCE)" +test -f "$(printenv DRUID_UI_SOURCE)" +test -s "$(printenv DRUID_UI_SOURCE)" +test -f "$(printenv DRUID_UI_DOCUMENT_SOURCE)" +test -s "$(printenv DRUID_UI_DOCUMENT_SOURCE)" echo "Uploading Druid UI package" +curl --fail-with-body --silent --show-error --request PUT --upload-file "$(printenv DRUID_UI_DOCUMENT_SOURCE)" \ + --header "Content-Type: text/plain; charset=utf-8" \ + --header "Cache-Control: public, max-age=31536000, immutable" \ + "$(printenv DRUID_UI_DOCUMENT_UPLOAD_URL)" curl --fail-with-body --silent --show-error --request PUT --upload-file "$(printenv DRUID_UI_SOURCE)" \ --header "Content-Type: application/wasm" \ --header "Cache-Control: public, max-age=31536000, immutable" \ diff --git a/apps/druid/core/services/runtime_ui_test.go b/apps/druid/core/services/runtime_ui_test.go index 9064bb8..cd8272d 100644 --- a/apps/druid/core/services/runtime_ui_test.go +++ b/apps/druid/core/services/runtime_ui_test.go @@ -1,6 +1,10 @@ package services import ( + "errors" + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -73,7 +77,7 @@ func TestPublishUIPackageRunsOneShotCommandWithEphemeralURL(t *testing.T) { } procedure := domain.ProcedureName(command.Name, 0, command.Command.Procedures[0]) values := command.ProcedureEnv[procedure] - if values["DRUID_UI_SOURCE"] != "private/dist/app.wasm" || values["DRUID_UI_UPLOAD_URL"] == "" { + if values["DRUID_UI_SOURCE"] != "private/dist/app.wasm" || values["DRUID_UI_UPLOAD_URL"] == "" || values["DRUID_UI_DOCUMENT_UPLOAD_URL"] == "" { t.Fatalf("procedure env = %#v", values) } script := command.Command.Procedures[0].Command[len(command.Command.Procedures[0].Command)-1] @@ -84,3 +88,84 @@ func TestPublishUIPackageRunsOneShotCommandWithEphemeralURL(t *testing.T) { t.Fatal("publish command must upload directly without digest negotiation") } } + +func TestPublishUIPackageFailureKeepsPreviousPackage(t *testing.T) { + store := newTestStateStore(t) + previous := domain.RuntimeUIPackage{URL: "http://packages/previous/app.wasm", Path: "private/dist/app.wasm"} + if err := store.CreateScroll(&domain.RuntimeScroll{ + ID: "ui-scroll", Root: "runtime://ui-scroll", ScrollName: "cached", ScrollYAML: cachedScrollYAML(""), + UIPackages: domain.RuntimeUIPackages{domain.RuntimeUIPackageScopePrivate: previous}, + }); err != nil { + t.Fatal(err) + } + backend := &fakeWorkerBackend{runCommand: func(ports.RuntimeCommand) (*int, error) { + return nil, errors.New("document upload failed") + }} + supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) + if _, err := supervisor.PublishUIPackage("ui-scroll", "private", ""); err == nil { + t.Fatal("expected upload failure") + } + current, err := store.GetScroll("ui-scroll") + if err != nil { + t.Fatal(err) + } + if current.UIPackages[domain.RuntimeUIPackageScopePrivate] != previous { + t.Fatalf("failed publication changed active package: %#v", current.UIPackages) + } +} + +func TestUIPublishRequiresBothAssetsAndStopsOnUploadFailure(t *testing.T) { + for _, test := range []struct { + name string + document bool + fail bool + wantErr bool + wantUploads int + }{ + {"missing document", false, false, true, 0}, + {"document upload fails", true, true, true, 1}, + {"complete package", true, false, false, 2}, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "private"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "private", "app.wasm"), []byte("wasm"), 0644); err != nil { + t.Fatal(err) + } + if test.document { + if err := os.WriteFile(filepath.Join(dir, "private", "app.openui"), []byte("root = Text(\"Test\")"), 0644); err != nil { + t.Fatal(err) + } + } + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$UPLOAD_LOG\"\n" + if test.fail { + script += "exit 7\n" + } + if err := os.WriteFile(filepath.Join(dir, "curl"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + args := uiPublishCommand().Procedures[0].Command[1:] + // Stored Scrolls expand environment variables before a worker sees the + // command. Request-scoped values must survive that round trip. + args[1] = os.ExpandEnv(args[1]) + command := exec.Command("sh", args...) + command.Dir = dir + log := filepath.Join(dir, "uploads") + command.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"), "UPLOAD_LOG="+log, + "DRUID_UI_SOURCE=private/app.wasm", "DRUID_UI_DOCUMENT_SOURCE=private/app.openui", "DRUID_UI_UPLOAD_URL=http://uploads/app.wasm", "DRUID_UI_DOCUMENT_UPLOAD_URL=http://uploads/app.openui") + output, err := command.CombinedOutput() + if (err != nil) != test.wantErr { + t.Fatalf("error = %v, output = %s", err, output) + } + data, _ := os.ReadFile(log) + if got := strings.Count(string(data), "--upload-file"); got != test.wantUploads { + t.Fatalf("uploads = %d, want %d", got, test.wantUploads) + } + if test.wantUploads == 2 && !strings.Contains(string(data), "app.openui") { + t.Fatal("missing document upload") + } + }) + } +} diff --git a/internal/core/ports/services_ports.go b/internal/core/ports/services_ports.go index 3a24f33..cc6cfa2 100644 --- a/internal/core/ports/services_ports.go +++ b/internal/core/ports/services_ports.go @@ -142,8 +142,9 @@ type RuntimeUIPackageUploadAction struct { // RuntimeUIPackageUpload exposes a short-lived upload URL and the immutable // public URL of the unique object it writes. type RuntimeUIPackageUpload struct { - UploadURL string - URL string + UploadURL string + DocumentUploadURL string + URL string } type RuntimeMaterialization struct { diff --git a/internal/runtime/docker/ui.go b/internal/runtime/docker/ui.go index 6f362d3..c76a5fe 100644 --- a/internal/runtime/docker/ui.go +++ b/internal/runtime/docker/ui.go @@ -18,7 +18,11 @@ func (b *Backend) CreateUIPackageUpload(ctx context.Context, action ports.Runtim if err != nil { return ports.RuntimeUIPackageUpload{}, err } - return ports.RuntimeUIPackageUpload{UploadURL: uploadURL, URL: b.uiPackagePublicURL(config, action)}, nil + documentURL, err := uipackage.PresignPut(ctx, path.Join(action.RequestID, "app.openui"), config) + if err != nil { + return ports.RuntimeUIPackageUpload{}, err + } + return ports.RuntimeUIPackageUpload{UploadURL: uploadURL, DocumentUploadURL: documentURL, URL: b.uiPackagePublicURL(config, action)}, nil } func (b *Backend) uiPackagePublicURL(config uipackage.S3Config, action ports.RuntimeUIPackageUploadAction) string { diff --git a/internal/runtime/kubernetes/ui.go b/internal/runtime/kubernetes/ui.go index 7d0e421..60f8659 100644 --- a/internal/runtime/kubernetes/ui.go +++ b/internal/runtime/kubernetes/ui.go @@ -22,7 +22,11 @@ func (b *Backend) CreateUIPackageUpload(ctx context.Context, action ports.Runtim if err != nil { return ports.RuntimeUIPackageUpload{}, err } - return ports.RuntimeUIPackageUpload{UploadURL: uploadURL, URL: b.uiPackagePublicURL(config, action)}, nil + documentURL, err := uipackage.PresignPut(ctx, path.Join(action.RequestID, "app.openui"), config) + if err != nil { + return ports.RuntimeUIPackageUpload{}, err + } + return ports.RuntimeUIPackageUpload{UploadURL: uploadURL, DocumentUploadURL: documentURL, URL: b.uiPackagePublicURL(config, action)}, nil } func (b *Backend) uiPackagePublicURL(config uipackage.S3Config, action ports.RuntimeUIPackageUploadAction) string {