diff --git a/.github/workflows/dashboard-run-test.yaml b/.github/workflows/dashboard-run-test.yaml index 1a91819e6..5eb7b918b 100644 --- a/.github/workflows/dashboard-run-test.yaml +++ b/.github/workflows/dashboard-run-test.yaml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 21 + node-version: 22 - name: Setup Go uses: actions/setup-go@v3 @@ -44,8 +44,9 @@ jobs: run: | cd ${{ github.workspace }}/cli/pkg/dashboard/frontend/test-app yarn install + yarn install:websites nitric run --ci & - sleep 15 + sleep 25 - name: Run Tests uses: cypress-io/github-action@v5 diff --git a/.github/workflows/dashboard-start-test.yaml b/.github/workflows/dashboard-start-test.yaml index 9eca8e3ab..1fc03d4c0 100644 --- a/.github/workflows/dashboard-start-test.yaml +++ b/.github/workflows/dashboard-start-test.yaml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 21 + node-version: 22 - name: Lint Dashboard working-directory: cli/pkg/dashboard/frontend @@ -51,7 +51,9 @@ jobs: run: | cd ${{ github.workspace }}/cli/pkg/dashboard/frontend/test-app yarn install + yarn install:websites nitric start --ci & + sleep 10 - name: Run Tests uses: cypress-io/github-action@v5 diff --git a/cmd/debug.go b/cmd/debug.go index ba54ec65f..df3e960fc 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -100,6 +100,9 @@ var specCmd = &cobra.Command{ batchRequirements, err := proj.CollectBatchRequirements() tui.CheckErr(err) + websiteRequirements, err := proj.CollectWebsiteRequirements() + tui.CheckErr(err) + additionalEnvFiles := []string{} if debugEnvFile != "" { @@ -115,7 +118,7 @@ var specCmd = &cobra.Command{ envVariables = map[string]string{} } - spec, err := collector.ServiceRequirementsToSpec(proj.Name, envVariables, serviceRequirements, batchRequirements) + spec, err := collector.ServiceRequirementsToSpec(proj.Name, envVariables, serviceRequirements, batchRequirements, websiteRequirements) tui.CheckErr(err) migrationImageContexts, err := collector.GetMigrationImageBuildContexts(serviceRequirements, batchRequirements, fs) diff --git a/cmd/run.go b/cmd/run.go index b690e59b6..4c147f2eb 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -106,7 +106,7 @@ var runCmd = &cobra.Command{ LogWriter: logWriter, LocalConfig: proj.LocalConfig, MigrationRunner: project.BuildAndRunMigrations, - LocalCloudMode: cloud.LocalCloudModeRun, + LocalCloudMode: cloud.RunMode, }) tui.CheckErr(err) runView.Send(local.LocalCloudStartStatusMsg{Status: local.Done}) @@ -149,6 +149,23 @@ var runCmd = &cobra.Command{ tui.CheckErr(err) } + websiteBuildUpdates, err := proj.BuildWebsites(loadEnv) + tui.CheckErr(err) + + if isNonInteractive() { + fmt.Println("building project websites") + for update := range websiteBuildUpdates { + for _, line := range strings.Split(strings.TrimSuffix(update.Message, "\n"), "\n") { + fmt.Printf("%s [%s]: %s\n", update.ServiceName, update.Status, line) + } + } + } else { + prog := teax.NewProgram(build.NewModel(websiteBuildUpdates, "Building Websites")) + // blocks but quits once the above updates channel is closed by the build process + _, err = prog.Run() + tui.CheckErr(err) + } + // Run the app code (project services) stopChan := make(chan bool) updatesChan := make(chan project.ServiceRunUpdate) @@ -179,6 +196,15 @@ var runCmd = &cobra.Command{ } }() + go func() { + err := proj.RunWebsites(localCloud) + if err != nil { + localCloud.Stop() + + tui.CheckErr(err) + } + }() + tui.CheckErr(err) // FIXME: This is a hack to get labelled logs into the TUI // We should refactor the system logs to be more generic diff --git a/cmd/stack.go b/cmd/stack.go index 276394605..6cfbaaed1 100644 --- a/cmd/stack.go +++ b/cmd/stack.go @@ -220,6 +220,9 @@ var stackUpdateCmd = &cobra.Command{ batchRequirements, err := proj.CollectBatchRequirements() tui.CheckErr(err) + websiteRequirements, err := proj.CollectWebsiteRequirements() + tui.CheckErr(err) + additionalEnvFiles := []string{} if envFile != "" { @@ -240,13 +243,13 @@ var stackUpdateCmd = &cobra.Command{ envVariables["NITRIC_BETA_PROVIDERS"] = "true" } - spec, err := collector.ServiceRequirementsToSpec(proj.Name, envVariables, serviceRequirements, batchRequirements) + spec, err := collector.ServiceRequirementsToSpec(proj.Name, envVariables, serviceRequirements, batchRequirements, websiteRequirements) tui.CheckErr(err) migrationImageContexts, err := collector.GetMigrationImageBuildContexts(serviceRequirements, batchRequirements, fs) tui.CheckErr(err) - // Build images from contexts and provide updates on the builds + // Build images from contexts and provide updates on the builds if len(migrationImageContexts) > 0 { migrationBuildUpdates, err := project.BuildMigrationImages(fs, migrationImageContexts, !noBuilder) tui.CheckErr(err) @@ -274,6 +277,23 @@ var stackUpdateCmd = &cobra.Command{ } } + websiteBuildUpdates, err := proj.BuildWebsites(envVariables) + tui.CheckErr(err) + + if isNonInteractive() { + fmt.Println("building project websites") + for update := range websiteBuildUpdates { + for _, line := range strings.Split(strings.TrimSuffix(update.Message, "\n"), "\n") { + fmt.Printf("%s [%s]: %s\n", update.ServiceName, update.Status, line) + } + } + } else { + prog := teax.NewProgram(build.NewModel(websiteBuildUpdates, "Building Websites")) + // blocks but quits once the above updates channel is closed by the build process + _, err = prog.Run() + tui.CheckErr(err) + } + providerStdout := make(chan string) // Step 4. Start the deployment provider server diff --git a/cmd/start.go b/cmd/start.go index 9de383df8..e699ca8ac 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -122,6 +122,21 @@ func createTlsCredentialsIfNotPresent(fs afero.Fs, projectDir string) { } } +func runInGoroutine( + fn func(*cloud.LocalCloud, <-chan bool, chan<- project.ServiceRunUpdate, map[string]string) error, + localCloud *cloud.LocalCloud, + stopChan chan bool, + updatesChan chan project.ServiceRunUpdate, + localEnv map[string]string, +) { + go func() { + if err := fn(localCloud, stopChan, updatesChan, localEnv); err != nil { + localCloud.Stop() + tui.CheckErr(err) + } + }() +} + var startCmd = &cobra.Command{ Use: "start", Short: "Run nitric services locally for development and testing", @@ -186,7 +201,7 @@ var startCmd = &cobra.Command{ LogWriter: logWriter, LocalConfig: proj.LocalConfig, MigrationRunner: project.BuildAndRunMigrations, - LocalCloudMode: cloud.LocalCloudModeStart, + LocalCloudMode: cloud.StartMode, }) tui.CheckErr(err) runView.Send(local.LocalCloudStartStatusMsg{Status: local.Done}) @@ -221,16 +236,14 @@ var startCmd = &cobra.Command{ } }() + runInGoroutine(proj.RunServicesWithCommand, localCloud, stopChan, updatesChan, localEnv) + + runInGoroutine(proj.RunBatchesWithCommand, localCloud, stopChan, updatesChan, localEnv) + + runInGoroutine(proj.RunWebsitesWithCommand, localCloud, stopChan, updatesChan, localEnv) + go func() { - err := proj.RunServicesWithCommand(localCloud, stopChan, updatesChan, localEnv) - if err != nil { - localCloud.Stop() - tui.CheckErr(err) - } - }() - // FIXME: Duplicate code - go func() { - err := proj.RunBatchesWithCommand(localCloud, stopChan, updatesChan, localEnv) + err := proj.RunWebsites(localCloud) if err != nil { localCloud.Stop() diff --git a/go.mod b/go.mod index 2c0dff4a6..4061bf548 100644 --- a/go.mod +++ b/go.mod @@ -22,19 +22,19 @@ require ( github.com/hashicorp/consul/sdk v0.13.0 github.com/hashicorp/go-getter v1.6.2 github.com/hashicorp/go-version v1.7.0 - github.com/nitrictech/nitric/core v0.0.0-20241003062412-76ea6275fb0b + github.com/nitrictech/nitric/core v0.0.0-20250305021715-06f92b813ce3 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.8.1 github.com/valyala/fasthttp v1.55.0 golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect - golang.org/x/mod v0.21.0 // indirect + golang.org/x/mod v0.22.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect google.golang.org/grpc v1.66.0 gopkg.in/yaml.v2 v2.4.0 ) require ( - github.com/Masterminds/semver/v3 v3.3.0 + github.com/Masterminds/semver/v3 v3.3.1 github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 @@ -53,7 +53,7 @@ require ( github.com/samber/lo v1.38.1 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.11.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 github.com/wk8/go-ordered-map/v2 v2.1.8 go.etcd.io/bbolt v1.3.6 golang.org/x/sync v0.10.0 @@ -115,7 +115,7 @@ require ( github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/fatih/color v1.17.0 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/firefart/nonamedreturns v1.0.5 // indirect @@ -275,18 +275,17 @@ require ( go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/sdk v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect - go.uber.org/atomic v1.10.0 // indirect go.uber.org/automaxprocs v1.5.3 // indirect - go.uber.org/multierr v1.8.0 // indirect - go.uber.org/zap v1.24.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.32.0 // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.6.0 // indirect - golang.org/x/tools v0.24.0 // indirect + golang.org/x/tools v0.28.0 // indirect google.golang.org/api v0.196.0 // indirect google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect diff --git a/go.sum b/go.sum index 2b83074cd..923cef0df 100644 --- a/go.sum +++ b/go.sum @@ -73,16 +73,16 @@ github.com/Djarvur/go-err113 v0.1.0 h1:uCRZZOdMQ0TZPHYTdYpoC0bLYJKPEHPUJ8MeAa51l github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0 h1:/fTUt5vmbkAcMBt4YQiuC23cV0kEsN1MVMNqeOW43cU= github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0/go.mod h1:ONJg5sxcbsdQQ4pOW8TGdTidT2TMAUy/2Xhr8mrYaao= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OpenPeeDeeP/depguard/v2 v2.2.0 h1:vDfG60vDtIuf0MEOhmLlLLSzqaRM8EMcgJPdp74zmpA= github.com/OpenPeeDeeP/depguard/v2 v2.2.0/go.mod h1:CIzddKRvLBC4Au5aYP/i3nyaWQ+ClszLIuVocRiCYFQ= github.com/Sereal/Sereal v0.0.0-20221130110801-16a4f76670cd h1:rP6LH3aVJTIxgTA3q79sSfnt8DvOlt17IRAklRBN+xo= github.com/Sereal/Sereal v0.0.0-20221130110801-16a4f76670cd/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= -github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= -github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= +github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= +github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/go-check-sumtype v0.1.4 h1:WCvlB3l5Vq5dZQTFmodqL2g68uHiSwwlWcT5a2FGK0c= github.com/alecthomas/go-check-sumtype v0.1.4/go.mod h1:WyYPfhfkdhyrdaligV6svFopZV8Lqdzn5pyVBaV6jhQ= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= @@ -117,8 +117,6 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -205,8 +203,8 @@ github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQt github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -585,8 +583,8 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nitrictech/nitric/cloud/common v0.0.0-20241003062412-76ea6275fb0b h1:wZeUrnmhYjdhSuL6ov+kVfuFJC9H14sk0kzEpt6aRoo= github.com/nitrictech/nitric/cloud/common v0.0.0-20241003062412-76ea6275fb0b/go.mod h1:ZsCdb3xbukhXAp9ZNbV6qWJqRC+eLkxhXy8bhs/cC2A= -github.com/nitrictech/nitric/core v0.0.0-20241003062412-76ea6275fb0b h1:ImQFk66gRM3v9A6qmPImOiV3HJMDAX93X5rplMKn6ok= -github.com/nitrictech/nitric/core v0.0.0-20241003062412-76ea6275fb0b/go.mod h1:9bQnYPqLzq8CcPk5MHT3phg19CWJhDlFOfdIv27lwwM= +github.com/nitrictech/nitric/core v0.0.0-20250305021715-06f92b813ce3 h1:7+D0QCKgGRyefo8BoTgnbL43schzkQxm6VP2x/oiyFY= +github.com/nitrictech/nitric/core v0.0.0-20250305021715-06f92b813ce3/go.mod h1:h1RNGBo+aYJHtQBjCzg6bdoU85OzcL6S2AvPnUd83+4= github.com/nunnatsa/ginkgolinter v0.16.2 h1:8iLqHIZvN4fTLDC0Ke9tbSZVcyVHoBs0HIbnVSxfHJk= github.com/nunnatsa/ginkgolinter v0.16.2/go.mod h1:4tWRinDN1FeJgU+iJANW/kz7xKN5nYRAOfJDQUS9dOQ= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= @@ -741,8 +739,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tdakkota/asciicheck v0.2.0 h1:o8jvnUANo0qXtnslk2d3nMKTFNlOnJjRrNcj0j9qkHM= @@ -832,17 +830,14 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3 go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= -go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -851,8 +846,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -897,8 +892,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -940,8 +935,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1026,16 +1021,16 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1115,8 +1110,8 @@ golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1232,7 +1227,6 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cloud/cloud.go b/pkg/cloud/cloud.go index 90163fa1c..27dff6bc4 100644 --- a/pkg/cloud/cloud.go +++ b/pkg/cloud/cloud.go @@ -36,6 +36,7 @@ import ( "github.com/nitrictech/cli/pkg/cloud/sql" "github.com/nitrictech/cli/pkg/cloud/storage" "github.com/nitrictech/cli/pkg/cloud/topics" + "github.com/nitrictech/cli/pkg/cloud/websites" "github.com/nitrictech/cli/pkg/cloud/websockets" "github.com/nitrictech/cli/pkg/grpcx" "github.com/nitrictech/cli/pkg/netx" @@ -52,9 +53,19 @@ type Subscribable[T any, A any] interface { type ServiceName = string +type Mode string + +const ( + // RunMode - services run directly on the host machine + RunMode Mode = "run" + // StartMode - services run in containers + StartMode Mode = "start" +) + type LocalCloud struct { serverLock sync.Mutex servers map[ServiceName]*server.NitricServer + mode Mode Apis *apis.LocalApiGatewayService Batch *batch.LocalBatchService @@ -67,10 +78,15 @@ type LocalCloud struct { Storage *storage.LocalStorageService Topics *topics.LocalTopicsAndSubscribersService Websockets *websockets.LocalWebsocketService + Websites *websites.LocalWebsiteService Queues *queues.LocalQueuesService Databases *sql.LocalSqlServer } +func (lc *LocalCloud) GetMode() Mode { + return lc.mode +} + // StartLocalNitric - starts the Nitric Server, including plugins and their local dependencies (e.g. local versions of cloud services) func (lc *LocalCloud) Stop() { for _, m := range lc.servers { @@ -230,22 +246,12 @@ func (lc *LocalCloud) AddService(serviceName string) (int, error) { return ports[0], nil } -// LocalCloudMode type run or start -type LocalCloudMode string - -const ( - // LocalCloudModeRun - run mode - LocalCloudModeRun LocalCloudMode = "run" - // LocalCloudModeStart - start mode - LocalCloudModeStart LocalCloudMode = "start" -) - type LocalCloudOptions struct { TLSCredentials *gateway.TLSCredentials LogWriter io.Writer LocalConfig localconfig.LocalConfiguration MigrationRunner sql.MigrationRunner - LocalCloudMode LocalCloudMode + LocalCloudMode Mode } func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { @@ -306,7 +312,7 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { connectionStringHost := "localhost" // Use the host.docker.internal address for connection strings with local cloud run mode - if opts.LocalCloudMode == LocalCloudModeRun { + if opts.LocalCloudMode == RunMode { connectionStringHost = dockerhost.GetInternalDockerHost() } @@ -315,8 +321,11 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { return nil, err } + localWebsites := websites.NewLocalWebsitesService(localGateway.GetApiAddress, opts.LocalCloudMode == StartMode) + return &LocalCloud{ servers: make(map[string]*server.NitricServer), + mode: opts.LocalCloudMode, Apis: localApis, Batch: localBatch, Http: localHttpProxy, @@ -325,6 +334,7 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { Storage: localStorage, Topics: localTopics, Websockets: localWebsockets, + Websites: localWebsites, Gateway: localGateway, Secrets: localSecrets, KeyValue: keyvalueService, diff --git a/pkg/cloud/websites/websites.go b/pkg/cloud/websites/websites.go new file mode 100644 index 000000000..69ebbebef --- /dev/null +++ b/pkg/cloud/websites/websites.go @@ -0,0 +1,241 @@ +// Copyright Nitric Pty Ltd. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package websites + +import ( + "errors" + "fmt" + "maps" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/asaskevich/EventBus" + + "github.com/nitrictech/cli/pkg/netx" + deploymentspb "github.com/nitrictech/nitric/core/pkg/proto/deployments/v1" +) + +type WebsitePb = deploymentspb.Website + +type Website struct { + *WebsitePb + + Name string + Directory string + OutputDirectory string + DevURL string + URL string +} + +type ( + WebsiteName = string + State = map[WebsiteName]Website + GetApiAddress = func(apiName string) string +) + +type LocalWebsiteService struct { + websiteRegLock sync.RWMutex + state State + port int + getApiAddress GetApiAddress + isStartCmd bool + + bus EventBus.Bus +} + +const localWebsitesTopic = "local_websites" + +func (l *LocalWebsiteService) publishState() { + l.bus.Publish(localWebsitesTopic, maps.Clone(l.state)) +} + +func (l *LocalWebsiteService) SubscribeToState(fn func(State)) { + // ignore the error, it's only returned if the fn param isn't a function + _ = l.bus.Subscribe(localWebsitesTopic, fn) +} + +// register - Register a new website +func (l *LocalWebsiteService) register(website Website) { + l.websiteRegLock.Lock() + defer l.websiteRegLock.Unlock() + + // Emulates the CDN URL used in a deployed environment + publicUrl := fmt.Sprintf("http://localhost:%d/%s", l.port, strings.TrimPrefix(website.BasePath, "/")) + + l.state[website.Name] = Website{ + WebsitePb: website.WebsitePb, + Name: website.Name, + DevURL: website.DevURL, + Directory: website.Directory, + URL: publicUrl, + } + + l.publishState() +} + +type staticSiteHandler struct { + website *Website + port int + devURL string + isStartCmd bool +} + +func (h staticSiteHandler) serveProxy(res http.ResponseWriter, req *http.Request) { + if h.devURL == "" { + http.Error(res, "The dev URL is not set for this website", http.StatusInternalServerError) + return + } + + targetUrl, err := url.Parse(h.devURL) + if err != nil { + http.Error(res, fmt.Sprintf("Invalid dev URL '%s': %v", h.devURL, err), http.StatusInternalServerError) + return + } + + // ignore proxy errors like unsupported protocol + if targetUrl == nil || targetUrl.Scheme == "" { + return + } + + // Reverse proxy request + proxy := httputil.NewSingleHostReverseProxy(targetUrl) + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + if err != nil { + var opErr *net.OpError + + if errors.As(err, &opErr) && opErr.Op == "dial" { + http.Error(w, "Connection to the dev server was refused. Check the URL and server status.", http.StatusServiceUnavailable) + } else { + http.Error(w, err.Error(), http.StatusBadGateway) + } + } + } + proxy.ServeHTTP(res, req) +} + +func (h staticSiteHandler) serveStatic(res http.ResponseWriter, req *http.Request) { + path := filepath.Join(h.website.OutputDirectory, req.URL.Path) + + fi, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + // if the file doesn't exist, serve the error page with a 404 status code + http.ServeFile(res, req, filepath.Join(h.website.OutputDirectory, h.website.ErrorDocument)) + + return + } + + http.Error(res, err.Error(), http.StatusInternalServerError) + + return + } + + if fi.IsDir() { + http.ServeFile(res, req, filepath.Join(h.website.OutputDirectory, h.website.IndexDocument)) + + return + } + + http.FileServer(http.Dir(h.website.OutputDirectory)).ServeHTTP(res, req) +} + +// ServeHTTP - Serve a static website from the local filesystem +func (h staticSiteHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) { + // If the website is running (i.e. start mode), proxy the request to the dev server + if h.isStartCmd { + h.serveProxy(res, req) + return + } + + h.serveStatic(res, req) +} + +// Start - Start the local website service +func (l *LocalWebsiteService) Start(websites []Website) error { + newLis, err := netx.GetNextListener(netx.MinPort(5000)) + if err != nil { + return err + } + + l.port = newLis.Addr().(*net.TCPAddr).Port + + _ = newLis.Close() + + mux := http.NewServeMux() + + // Register the API proxy handler + mux.HandleFunc("/api/{name}/", func(res http.ResponseWriter, req *http.Request) { + apiName := req.PathValue("name") + + apiAddress := l.getApiAddress(apiName) + if apiAddress == "" { + http.Error(res, fmt.Sprintf("api %s not found", apiName), http.StatusNotFound) + return + } + + targetPath := strings.TrimPrefix(req.URL.Path, fmt.Sprintf("/api/%s", apiName)) + targetUrl, _ := url.Parse(apiAddress) + + proxy := httputil.NewSingleHostReverseProxy(targetUrl) + req.URL.Path = targetPath + + proxy.ServeHTTP(res, req) + }) + + // Register the SPA handler for each website + for i := range websites { + website := &websites[i] + spa := staticSiteHandler{website: website, port: l.port, devURL: website.DevURL, isStartCmd: l.isStartCmd} + + if website.BasePath == "/" { + mux.Handle("/", spa) + } else { + mux.Handle(website.BasePath+"/", http.StripPrefix(website.BasePath+"/", spa)) + } + } + + // Start the server with the multiplexer + go func() { + addr := fmt.Sprintf(":%d", l.port) + if err := http.ListenAndServe(addr, mux); err != nil { + fmt.Printf("Failed to start server: %s\n", err) + } + }() + + // Register the websites + for _, website := range websites { + l.register(website) + } + + return nil +} + +func NewLocalWebsitesService(getApiAddress GetApiAddress, isStartCmd bool) *LocalWebsiteService { + return &LocalWebsiteService{ + state: State{}, + bus: EventBus.New(), + getApiAddress: getApiAddress, + isStartCmd: isStartCmd, + } +} diff --git a/pkg/collector/spec.go b/pkg/collector/spec.go index e094e4d65..6ddde5bcb 100644 --- a/pkg/collector/spec.go +++ b/pkg/collector/spec.go @@ -24,6 +24,8 @@ import ( "errors" "fmt" "net/url" + "os" + "path/filepath" "regexp" "slices" "strings" @@ -1042,7 +1044,7 @@ func checkServiceRequirementErrors(allServiceRequirements []*ServiceRequirements } // convert service requirements to a cloud bill of materials -func ServiceRequirementsToSpec(projectName string, environmentVariables map[string]string, allServiceRequirements []*ServiceRequirements, allBatchRequirements []*BatchRequirements) (*deploymentspb.Spec, error) { +func ServiceRequirementsToSpec(projectName string, environmentVariables map[string]string, allServiceRequirements []*ServiceRequirements, allBatchRequirements []*BatchRequirements, websiteRequirements []*deploymentspb.Website) (*deploymentspb.Spec, error) { if err := checkServiceRequirementErrors(allServiceRequirements, allBatchRequirements); err != nil { return nil, err } @@ -1180,6 +1182,30 @@ func ServiceRequirementsToSpec(projectName string, environmentVariables map[stri }) } + for _, website := range websiteRequirements { + localDir, ok := website.AssetSource.(*deploymentspb.Website_LocalDirectory) + if !ok { + projectErrors.Add(fmt.Errorf("website asset source must be a local directory")) + continue + } + + cleanedPath := strings.TrimRight(localDir.LocalDirectory, string(os.PathSeparator)) + // Get the parent directory + parentDir := filepath.Dir(cleanedPath) + // Extract the directory name from the parent path + _, name := filepath.Split(parentDir) + + newSpec.Resources = append(newSpec.Resources, &deploymentspb.Resource{ + Id: &resourcespb.ResourceIdentifier{ + Name: name, + Type: resourcespb.ResourceType_Website, + }, + Config: &deploymentspb.Resource_Website{ + Website: website, + }, + }) + } + return newSpec, projectErrors.Error() } diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index 0f6061435..d9759caa4 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -52,6 +52,7 @@ import ( "github.com/nitrictech/cli/pkg/cloud/sql" "github.com/nitrictech/cli/pkg/cloud/storage" "github.com/nitrictech/cli/pkg/cloud/topics" + "github.com/nitrictech/cli/pkg/cloud/websites" "github.com/nitrictech/cli/pkg/cloud/websockets" "github.com/nitrictech/cli/pkg/project" "github.com/nitrictech/cli/pkg/update" @@ -156,8 +157,16 @@ type HttpProxySpec struct { Target string `json:"target"` } +type WebsiteSpec struct { + Name string `json:"name"` + URL string `json:"url"` + DevURL string `json:"devUrl"` + Directory string `json:"directory"` +} + type Dashboard struct { resourcesLock sync.Mutex + localCloudMode cloud.Mode project *project.Project storageService *storage.LocalStorageService gatewayService *gateway.LocalGatewayService @@ -174,6 +183,7 @@ type Dashboard struct { secrets []*SecretSpec sqlDatabases []*SQLDatabaseSpec websockets []WebsocketSpec + websites []WebsiteSpec subscriptions []*SubscriberSpec notifications []*NotifierSpec httpProxies []*HttpProxySpec @@ -201,6 +211,7 @@ type DashboardResponse struct { Schedules []ScheduleSpec `json:"schedules"` Topics []*TopicSpec `json:"topics"` Websockets []WebsocketSpec `json:"websockets"` + Websites []WebsiteSpec `json:"websites"` Subscriptions []*SubscriberSpec `json:"subscriptions"` Notifications []*NotifierSpec `json:"notifications"` Stores []*KeyValueSpec `json:"stores"` @@ -221,6 +232,7 @@ type DashboardResponse struct { CurrentVersion string `json:"currentVersion"` LatestVersion string `json:"latestVersion"` Connected bool `json:"connected"` + LocalCloudMode cloud.Mode `json:"localCloudMode"` } type Bucket struct { @@ -630,6 +642,32 @@ func (d *Dashboard) updateSqlDatabases(state sql.State) { d.refresh() } +func (d *Dashboard) handleWebsites(state websites.State) { + d.resourcesLock.Lock() + defer d.resourcesLock.Unlock() + + websites := []WebsiteSpec{} + + for name, site := range state { + websites = append(websites, WebsiteSpec{ + Name: strings.TrimPrefix(name, "websites_"), + URL: site.URL, + DevURL: site.DevURL, + Directory: site.Directory, + }) + } + + if len(websites) > 0 { + slices.SortFunc(websites, func(a, b WebsiteSpec) int { + return compare(a.Name, b.Name) + }) + } + + d.websites = websites + + d.refresh() +} + func (d *Dashboard) refresh() { if !d.noBrowser && !d.browserHasOpened { d.openBrowser() @@ -826,6 +864,7 @@ func (d *Dashboard) sendStackUpdate() error { SQLDatabases: d.sqlDatabases, Schedules: d.schedules, Websockets: d.websockets, + Websites: d.websites, Policies: d.policies, Queues: d.queues, Secrets: d.secrets, @@ -842,6 +881,7 @@ func (d *Dashboard) sendStackUpdate() error { CurrentVersion: currentVersion, LatestVersion: latestVersion, Connected: d.isConnected(), + LocalCloudMode: d.localCloudMode, } // Encode the response as JSON @@ -894,6 +934,7 @@ func New(noBrowser bool, localCloud *cloud.LocalCloud, project *project.Project) wsWebSocket := melody.New() dash := &Dashboard{ + localCloudMode: localCloud.GetMode(), project: project, storageService: localCloud.Storage, gatewayService: localCloud.Gateway, @@ -913,6 +954,7 @@ func New(noBrowser bool, localCloud *cloud.LocalCloud, project *project.Project) subscriptions: []*SubscriberSpec{}, notifications: []*NotifierSpec{}, websockets: []WebsocketSpec{}, + websites: []WebsiteSpec{}, stores: []*KeyValueSpec{}, sqlDatabases: []*SQLDatabaseSpec{}, secrets: []*SecretSpec{}, @@ -943,6 +985,7 @@ func New(noBrowser bool, localCloud *cloud.LocalCloud, project *project.Project) localCloud.Storage.SubscribeToState(dash.updateBucketNotifications) localCloud.Http.SubscribeToState(dash.updateHttpProxies) localCloud.Databases.SubscribeToState(dash.updateSqlDatabases) + localCloud.Websites.SubscribeToState(dash.handleWebsites) // subscribe to history events from gateway localCloud.Apis.SubscribeToAction(dash.handleApiHistory) diff --git a/pkg/dashboard/frontend/cypress.config.ts b/pkg/dashboard/frontend/cypress.config.ts index 69754451b..28a8837e4 100644 --- a/pkg/dashboard/frontend/cypress.config.ts +++ b/pkg/dashboard/frontend/cypress.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ setupNodeEvents(on, config) { // implement node event listeners here }, + chromeWebSecurity: false, }, }) diff --git a/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts b/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts index 2a67282e7..d768988c7 100644 --- a/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/a11y.cy.ts @@ -9,6 +9,7 @@ describe('a11y test suite', () => { '/topics', '/jobs', '/websockets', + '/websites', '/logs', '/not-found', ] diff --git a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts index 4ab7c63be..9381463f3 100644 --- a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts @@ -19,6 +19,7 @@ const expectedNodes = [ 'services/my-test-secret.ts', 'my-first-secret', 'my-second-secret', + 'CDN', ] describe('Architecture Spec', () => { diff --git a/pkg/dashboard/frontend/cypress/e2e/logs.cy.ts b/pkg/dashboard/frontend/cypress/e2e/logs.cy.ts index 02ce66624..3592fc092 100644 --- a/pkg/dashboard/frontend/cypress/e2e/logs.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/logs.cy.ts @@ -46,7 +46,7 @@ describe('logs test suite', () => { cy.get('div[data-value="nitric"]').click() - cy.getTestEl('logs').children().should('have.length', 3) + cy.getTestEl('logs').children().should('have.length.at.least', 3) cy.getTestEl('filter-logs-reset-btn').click() diff --git a/pkg/dashboard/frontend/cypress/e2e/websites.cy.ts b/pkg/dashboard/frontend/cypress/e2e/websites.cy.ts new file mode 100644 index 000000000..9bf2e958f --- /dev/null +++ b/pkg/dashboard/frontend/cypress/e2e/websites.cy.ts @@ -0,0 +1,47 @@ +describe('Websites Spec', () => { + beforeEach(() => { + cy.viewport('macbook-16') + }) + + const expectedWebsites = ['vite-website', 'docs-website'] + + it('should retrieve correct websites in list', () => { + cy.visit('/websites') + cy.get('h2').should('contain.text', 'docs-website') + + expectedWebsites.forEach((id) => { + cy.get(`[data-rct-item-id="${id}"]`).should('exist') + }) + }) + + expectedWebsites.forEach((id) => { + it(`should render website ${id}`, () => { + cy.visit('/websites') + cy.get(`[data-rct-item-id="${id}"]`).click() + cy.get('h2').should('contain.text', id) + + const pathMap = { + 'vite-website': '', + 'docs-website': 'docs', + } + + const url = `http://localhost:5000/${pathMap[id]}` + + // check iframe url + cy.get('iframe').should('have.attr', 'src', url) + + cy.visit(url) + + const titleMap = { + 'vite-website': 'Hello Nitric!', + 'docs-website': 'Hello Nitric Docs Test!', + } + + const title = titleMap[id] + + cy.origin('http://localhost:5000', { args: { title } }, ({ title }) => { + cy.get('h1').should('have.text', title) + }) + }) + }) +}) diff --git a/pkg/dashboard/frontend/src/components/architecture/nodes/WebsitesNode.tsx b/pkg/dashboard/frontend/src/components/architecture/nodes/WebsitesNode.tsx new file mode 100644 index 000000000..7ee2e1e5d --- /dev/null +++ b/pkg/dashboard/frontend/src/components/architecture/nodes/WebsitesNode.tsx @@ -0,0 +1,42 @@ +import { type ComponentType } from 'react' + +import type { Website } from '@/types' +import type { NodeProps } from 'reactflow' +import NodeBase, { type NodeBaseData } from './NodeBase' +import SitesList from '@/components/websites/SitesList' + +export type WebsitesNodeData = NodeBaseData + +export const WebsitesNode: ComponentType> = ( + props, +) => { + const { data } = props + + const websites = data.resource + + const rootWebsite = websites.find((website) => + /localhost:\d+$/.test(website.url.replace(/\/$/, '')), + ) + + const description = `${websites.length === 1 ? 'website' : 'websites'} stored in a bucket and served via CDN.` + + return ( + site !== rootWebsite)} + /> + ) : null, + }} + /> + ) +} diff --git a/pkg/dashboard/frontend/src/components/architecture/styles.css b/pkg/dashboard/frontend/src/components/architecture/styles.css index 94738691a..179daa904 100644 --- a/pkg/dashboard/frontend/src/components/architecture/styles.css +++ b/pkg/dashboard/frontend/src/components/architecture/styles.css @@ -139,3 +139,10 @@ --nitric-node-to: #334155; /* Slate 700 */ --nitric-node-icon-color: #475569; /* Slate 600 */ } + +.react-flow__node-websites { + --nitric-node-from: #06b6d4; /* Cyan 600 */ + --nitric-node-via: #22d3ee; /* Cyan 400 */ + --nitric-node-to: #0891b2; /* Cyan 700 */ + --nitric-node-icon-color: #06b6d4; /* Cyan 600 */ +} diff --git a/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx b/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx index 94116d507..07f0d31b0 100644 --- a/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx +++ b/pkg/dashboard/frontend/src/components/layout/AppLayout/AppLayout.tsx @@ -15,6 +15,7 @@ import { CircleStackIcon, LockClosedIcon, CpuChipIcon, + WindowIcon, } from '@heroicons/react/24/outline' import { cn } from '@/lib/utils' import { useWebSocket } from '../../../lib/hooks/use-web-socket' @@ -147,6 +148,11 @@ const AppLayout: React.FC = ({ href: '/websockets', icon: ChatBubbleLeftRightIcon, }, + { + name: 'Websites', + href: '/websites', + icon: WindowIcon, + }, // { name: "Key Value Stores", href: "#", icon: FolderIcon, current: false }, ] diff --git a/pkg/dashboard/frontend/src/components/websites/SiteExplorer.tsx b/pkg/dashboard/frontend/src/components/websites/SiteExplorer.tsx new file mode 100644 index 000000000..467830bc8 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/websites/SiteExplorer.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from 'react' + +import { Loading } from '../shared' +import { useWebSocket } from '../../lib/hooks/use-web-socket' +import AppLayout from '../layout/AppLayout' +import type { Website } from '@/types' +import BreadCrumbs from '../layout/BreadCrumbs' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select' +import NotFoundAlert from '../shared/NotFoundAlert' +import SiteTreeView from './SiteTreeView' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu' +import { EllipsisVerticalIcon } from '@heroicons/react/24/outline' + +const LOCAL_STORAGE_KEY = 'nitric-local-dash-website-history' + +const SiteExplorer = () => { + const [selectedWebsite, setSelectedWebsite] = useState() + const { data, loading } = useWebSocket() + + const { websites, localCloudMode } = data || {} + + useEffect(() => { + if (websites?.length && !selectedWebsite) { + const previousWebsite = localStorage.getItem( + `${LOCAL_STORAGE_KEY}-last-website`, + ) + + setSelectedWebsite( + websites.find((b) => b.name === previousWebsite) || websites[0], + ) + } + }, [websites]) + + useEffect(() => { + if (selectedWebsite) { + // set history + localStorage.setItem( + `${LOCAL_STORAGE_KEY}-last-website`, + selectedWebsite.name, + ) + } + }, [selectedWebsite]) + + return ( + +
+ Websites +
+ { + setSelectedWebsite(b) + }} + websites={websites} + /> + + ) + } + > + + {websites && selectedWebsite ? ( +
+
+
+
+ + Websites +

+ {selectedWebsite.name} +

+
+ +
+ + {!data?.websites.some( + (s) => s.name === selectedWebsite.name, + ) && ( + + Website not found. It might have been updated or removed. + Select another website. + + )} +
+ +
+ + + + + + + + + + Open in VSCode + + + + + Open in a new tab + + + + + +
+
+
+
+ {selectedWebsite.url} +
+
+ {localCloudMode === 'run' || selectedWebsite.devUrl ? ( +