From 5cdab36ad72530678fb62dd703a86813f6874534 Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 21 Jan 2025 15:52:06 +1100 Subject: [PATCH 01/31] websites for nitric run --- cmd/run.go | 26 ++++++ pkg/cloud/cloud.go | 5 + pkg/cloud/websites/websites.go | 120 ++++++++++++++++++++++++ pkg/project/config.go | 14 +++ pkg/project/project.go | 110 ++++++++++++++++++++++ pkg/project/website.go | 141 +++++++++++++++++++++++++++++ pkg/view/tui/commands/local/run.go | 25 +++++ 7 files changed, 441 insertions(+) create mode 100644 pkg/cloud/websites/websites.go create mode 100644 pkg/project/website.go diff --git a/cmd/run.go b/cmd/run.go index b690e59b6..6cbe38618 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -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/pkg/cloud/cloud.go b/pkg/cloud/cloud.go index 90163fa1c..a56213a7e 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" @@ -67,6 +68,7 @@ type LocalCloud struct { Storage *storage.LocalStorageService Topics *topics.LocalTopicsAndSubscribersService Websockets *websockets.LocalWebsocketService + Websites *websites.LocalWebsiteService Queues *queues.LocalQueuesService Databases *sql.LocalSqlServer } @@ -315,6 +317,8 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { return nil, err } + localWebsites := websites.NewLocalWebsitesService() + return &LocalCloud{ servers: make(map[string]*server.NitricServer), Apis: localApis, @@ -325,6 +329,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..ca58b27e8 --- /dev/null +++ b/pkg/cloud/websites/websites.go @@ -0,0 +1,120 @@ +// 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 ( + "fmt" + "maps" + "net" + "net/http" + "sync" + + "github.com/asaskevich/EventBus" + "github.com/nitrictech/cli/pkg/netx" + "github.com/nitrictech/nitric/core/pkg/logger" +) + +type ( + WebsiteName = string + State = map[WebsiteName]string +) + +type LocalWebsiteService struct { + websiteRegLock sync.RWMutex + state State + + 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(websiteName string, port int) { + if _, exists := l.state[websiteName]; exists { + logger.Warnf("Website %s is already registered", websiteName) + return + } + + l.websiteRegLock.Lock() + defer l.websiteRegLock.Unlock() + + l.state[websiteName] = fmt.Sprintf("http://localhost:%d", port) + + l.publishState() +} + +// deregister - Deregister a website +func (l *LocalWebsiteService) deregister(websiteName string) { + l.websiteRegLock.Lock() + defer l.websiteRegLock.Unlock() + + delete(l.state, websiteName) + + l.publishState() +} + +// Serve - Serve a website from the local filesystem +func (l *LocalWebsiteService) Serve(websiteName string, path string) error { + // serve the website from path using http server + fs := http.FileServer(http.Dir(path)) + + // Create a new ServeMux to handle the request + mux := http.NewServeMux() + mux.Handle("/", fs) + + // get an available port + ports, err := netx.TakePort(1) + if err != nil { + return err + } + + port := ports[0] // Take the first available port + + // Start the HTTP server on the assigned port + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + l.deregister(websiteName) + return fmt.Errorf("failed to start server on port %d: %w", port, err) + } + + go func() { + if err := http.Serve(listener, mux); err != nil { + logger.Errorf("Error serving website %s: %s", websiteName, err.Error()) + l.deregister(websiteName) + } + }() + + l.register(websiteName, port) + + return nil +} + +func NewLocalWebsitesService() *LocalWebsiteService { + return &LocalWebsiteService{ + state: State{}, + bus: EventBus.New(), + } +} diff --git a/pkg/project/config.go b/pkg/project/config.go index 6e815f01c..d71e2c963 100644 --- a/pkg/project/config.go +++ b/pkg/project/config.go @@ -84,12 +84,26 @@ type BatchConfiguration struct { BaseServiceConfiguration `yaml:",inline"` } +type Build struct { + Command string `yaml:"command"` + Output string `yaml:"output"` +} + +type WebsiteConfiguration struct { + BaseServiceConfiguration `yaml:",inline"` + + Build Build `yaml:"build"` + IndexPage string `yaml:"index,omitempty"` + ErrorPage string `yaml:"error,omitempty"` +} + type ProjectConfiguration struct { Name string `yaml:"name"` Directory string `yaml:"-"` Services []ServiceConfiguration `yaml:"services"` Ports map[string]int `yaml:"ports,omitempty"` Batches []BatchConfiguration `yaml:"batch-services"` + Websites []WebsiteConfiguration `yaml:"websites"` Runtimes map[string]RuntimeConfiguration `yaml:"runtimes,omitempty"` Preview []preview.Feature `yaml:"preview,omitempty"` } diff --git a/pkg/project/project.go b/pkg/project/project.go index 9177acf91..8b9e5870a 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -54,6 +54,7 @@ type Project struct { services []Service batches []Batch + websites []Website } func (p *Project) GetServices() []Service { @@ -64,6 +65,10 @@ func (p *Project) GetBatchServices() []Batch { return p.batches } +func (p *Project) GetWebsites() []Website { + return p.websites +} + // TODO: Reduce duplicate code // BuildBatches - Builds all the batches in the project func (p *Project) BuildBatches(fs afero.Fs, useBuilder bool) (chan ServiceBuildUpdate, error) { @@ -176,6 +181,61 @@ func (p *Project) BuildServices(fs afero.Fs, useBuilder bool) (chan ServiceBuild return updatesChan, nil } +// BuildWebsites - Builds all the websites in the project via build command +func (p *Project) BuildWebsites(env map[string]string) (chan ServiceBuildUpdate, error) { + updatesChan := make(chan ServiceBuildUpdate) + + maxConcurrentBuilds := make(chan struct{}, min(goruntime.NumCPU(), goruntime.GOMAXPROCS(0))) + + waitGroup := sync.WaitGroup{} + + for _, website := range p.websites { + waitGroup.Add(1) + // Create writer + serviceBuildUpdateWriter := NewBuildUpdateWriter(website.Name, updatesChan) + + go func(site Website, writer io.Writer) { + // Acquire a token by filling the maxConcurrentBuilds channel + // this will block once the buffer is full + maxConcurrentBuilds <- struct{}{} + + // Start goroutine + if err := site.Build(updatesChan, env); err != nil { + updatesChan <- ServiceBuildUpdate{ + ServiceName: site.Name, + Err: err, + Message: err.Error(), + Status: ServiceBuildStatus_Error, + } + + } else { + updatesChan <- ServiceBuildUpdate{ + ServiceName: site.Name, + Message: "Build Complete", + Status: ServiceBuildStatus_Complete, + } + } + + // release our lock + <-maxConcurrentBuilds + + waitGroup.Done() + }(website, serviceBuildUpdateWriter) + } + + go func() { + waitGroup.Wait() + // Drain the semaphore to make sure all goroutines have finished + for i := 0; i < cap(maxConcurrentBuilds); i++ { + maxConcurrentBuilds <- struct{}{} + } + + close(updatesChan) + }() + + return updatesChan, nil +} + func (p *Project) collectServiceRequirements(service Service) (*collector.ServiceRequirements, error) { serviceRequirements := collector.NewServiceRequirements(service.Name, service.GetFilePath(), service.Type) @@ -526,6 +586,27 @@ func (p *Project) RunServices(localCloud *cloud.LocalCloud, stop <-chan bool, up return group.Wait() } +// RunWebsites - Runs all the websites as http servers +// use the stop channel to stop all running websites +func (p *Project) RunWebsites(localCloud *cloud.LocalCloud) error { + group, _ := errgroup.WithContext(context.TODO()) + + for _, site := range p.websites { + s := site + + group.Go(func() error { + absoluteOutputPath, err := s.GetAbsoluteOutputPath() + if err != nil { + return err + } + + return localCloud.Websites.Serve(s.Name, absoluteOutputPath) + }) + } + + return group.Wait() +} + func (pc *ProjectConfiguration) pathToNormalizedServiceName(servicePath string) string { // Add the project name as a prefix to group service images servicePath = fmt.Sprintf("%s_%s", pc.Name, servicePath) @@ -545,6 +626,7 @@ func (pc *ProjectConfiguration) pathToNormalizedServiceName(servicePath string) func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig *localconfig.LocalConfiguration, fs afero.Fs) (*Project, error) { services := []Service{} batches := []Batch{} + websites := []Website{} matches := map[string]string{} @@ -654,6 +736,33 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig * } } + for _, websiteSpec := range projectConfig.Websites { + if websiteSpec.Build.Output == "" { + return nil, fmt.Errorf("no build output provided for website %s", websiteSpec.GetBasedir()) + } + + if websiteSpec.IndexPage == "" { + websiteSpec.IndexPage = "index.html" + } + + if websiteSpec.ErrorPage == "" { + websiteSpec.ErrorPage = "index.html" + } + + projectRelativeWebsiteFolder := filepath.Join(projectConfig.Directory, websiteSpec.GetBasedir()) + + websiteName := fmt.Sprintf("websites_%s", strings.ToLower(projectRelativeWebsiteFolder)) + + websites = append(websites, Website{ + Name: websiteName, + basedir: websiteSpec.GetBasedir(), + outputPath: websiteSpec.Build.Output, + buildCmd: websiteSpec.Build.Command, + indexPage: websiteSpec.IndexPage, + errorPage: websiteSpec.ErrorPage, + }) + } + // create an empty local configuration if none is provided if localConfig == nil { localConfig = &localconfig.LocalConfiguration{} @@ -666,6 +775,7 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig * LocalConfig: *localConfig, services: services, batches: batches, + websites: websites, } if len(project.batches) > 0 && !slices.Contains(project.Preview, preview.Feature_BatchServices) { diff --git a/pkg/project/website.go b/pkg/project/website.go new file mode 100644 index 000000000..c039a3b32 --- /dev/null +++ b/pkg/project/website.go @@ -0,0 +1,141 @@ +// 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 project + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Website struct { + Name string + + // the base directory for the website source files + basedir string + + // the build command to build the website + buildCmd string + + // the path to the website source files + outputPath string + + // index page for the website + indexPage string + + // error page for the website + errorPage string +} + +func (s *Website) GetOutputPath() string { + return filepath.Join(s.basedir, s.outputPath) +} + +func (s *Website) GetAbsoluteOutputPath() (string, error) { + return filepath.Abs(s.GetOutputPath()) +} + +// Run - runs the website using the provided command. TODO +func (s *Website) Run(stop <-chan bool, updates chan<- ServiceRunUpdate, env map[string]string) error { + return nil +} + +// Build - builds the website using the provided command +func (s *Website) Build(updates chan ServiceBuildUpdate, env map[string]string) error { + if s.buildCmd == "" { + return fmt.Errorf("no build command provided for website %s", s.basedir) + } + + commandParts := strings.Split(s.buildCmd, " ") + cmd := exec.Command( + commandParts[0], + commandParts[1:]..., + ) + + cmd.Env = append([]string{}, os.Environ()...) + cmd.Dir = s.basedir + + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + cmd.Stdout = &serviceBuildUpdateWriter{ + buildUpdateChan: updates, + serviceName: s.Name, + } + + cmd.Stderr = &serviceBuildUpdateWriter{ + buildUpdateChan: updates, + serviceName: s.Name, + } + + errChan := make(chan error) + + go func() { + err := cmd.Start() + if err != nil { + errChan <- fmt.Errorf("error building website %s: %w", s.Name, err) + } else { + updates <- ServiceBuildUpdate{ + ServiceName: s.Name, + Status: ServiceBuildStatus_InProgress, + Message: fmt.Sprintf("building website %s", s.GetOutputPath()), + } + } + + err = cmd.Wait() + if err != nil { + // provide runtime errors as a run update rather than as a fatal error + updates <- ServiceBuildUpdate{ + ServiceName: s.Name, + Status: ServiceBuildStatus_Error, + Err: err, + } + } + + errChan <- nil + }() + + // go func(cmd *exec.Cmd) { + // <-stop + + // err := cmd.Process.Signal(syscall.SIGTERM) + // if err != nil { + // _ = cmd.Process.Kill() + // } + // }(cmd) + + err := <-errChan + + if err != nil { + updates <- ServiceBuildUpdate{ + ServiceName: s.Name, + Status: ServiceBuildStatus_Error, + Err: err, + } + } else { + // updates <- ServiceBuildUpdate{ + // ServiceName: s.Name, + // Status: ServiceBuildStatus_Complete, + // Message: fmt.Sprintf("website %s built successfully", s.Name), + // } + } + + return err +} diff --git a/pkg/view/tui/commands/local/run.go b/pkg/view/tui/commands/local/run.go index 5fc313001..7aac736a1 100644 --- a/pkg/view/tui/commands/local/run.go +++ b/pkg/view/tui/commands/local/run.go @@ -20,6 +20,7 @@ import ( "fmt" "slices" "sort" + "strings" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -32,6 +33,7 @@ import ( "github.com/nitrictech/cli/pkg/cloud/schedules" "github.com/nitrictech/cli/pkg/cloud/sql" "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/validation" "github.com/nitrictech/cli/pkg/view/tui" @@ -74,6 +76,11 @@ type DatabaseSummary struct { status string } +type WebsiteSummary struct { + name string + url string +} + type TuiModel struct { localCloud *cloud.LocalCloud apis []ApiSummary @@ -82,6 +89,7 @@ type TuiModel struct { topics []TopicSummary schedules []ScheduleSummary databases []DatabaseSummary + websites []WebsiteSummary resources *resources.LocalResourcesState @@ -103,6 +111,7 @@ func (t *TuiModel) Init() tea.Cmd { reactive.ListenFor(t.reactiveSub, t.localCloud.Schedules.SubscribeToState) reactive.ListenFor(t.reactiveSub, t.localCloud.Topics.SubscribeToState) + reactive.ListenFor(t.reactiveSub, t.localCloud.Websites.SubscribeToState) return t.reactiveSub.AwaitNextMsg() } @@ -222,6 +231,17 @@ func (t *TuiModel) ReactiveUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { } t.schedules = newSchedulesSummary + case websites.State: + newWebsitesSummary := []WebsiteSummary{} + + for websiteName, url := range state { + newWebsitesSummary = append(newWebsitesSummary, WebsiteSummary{ + name: strings.TrimPrefix(websiteName, "websites_"), + url: url, + }) + } + + t.websites = newWebsitesSummary } return t, t.reactiveSub.AwaitNextMsg() @@ -289,6 +309,11 @@ func (t *TuiModel) View() string { v.Addln(database.status).WithStyle(textHighlight) } + for _, site := range t.websites { + v.Addf("site:%s - ", site.name) + v.Addln(site.url).WithStyle(textHighlight) + } + if t.resources != nil { if len(t.resources.ServiceErrors) > 0 { v.Break() From 356647bcb852e1a9b0218dd9a4761dbda5b81897 Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 21 Jan 2025 16:20:45 +1100 Subject: [PATCH 02/31] wip nitric start for websites --- cmd/start.go | 10 +++++ pkg/project/config.go | 5 +++ pkg/project/project.go | 36 ++++++++++++++- pkg/project/website.go | 99 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 9de383df8..24097455b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -238,6 +238,16 @@ var startCmd = &cobra.Command{ } }() + // FIXME: Duplicate code + go func() { + err := proj.RunWebsitesWithCommand(localCloud, stopChan, updatesChan, localEnv) + if err != nil { + localCloud.Stop() + + 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 systemChan := make(chan project.ServiceRunUpdate) diff --git a/pkg/project/config.go b/pkg/project/config.go index d71e2c963..add5f58c0 100644 --- a/pkg/project/config.go +++ b/pkg/project/config.go @@ -89,10 +89,15 @@ type Build struct { Output string `yaml:"output"` } +type Dev struct { + Command string `yaml:"command"` +} + type WebsiteConfiguration struct { BaseServiceConfiguration `yaml:",inline"` Build Build `yaml:"build"` + Dev Dev `yaml:"dev"` IndexPage string `yaml:"index,omitempty"` ErrorPage string `yaml:"error,omitempty"` } diff --git a/pkg/project/project.go b/pkg/project/project.go index 8b9e5870a..e2348ae34 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -587,7 +587,6 @@ func (p *Project) RunServices(localCloud *cloud.LocalCloud, stop <-chan bool, up } // RunWebsites - Runs all the websites as http servers -// use the stop channel to stop all running websites func (p *Project) RunWebsites(localCloud *cloud.LocalCloud) error { group, _ := errgroup.WithContext(context.TODO()) @@ -607,6 +606,40 @@ func (p *Project) RunWebsites(localCloud *cloud.LocalCloud) error { return group.Wait() } +// RunWebsitesWithCommand - Runs all the websites using a startup command +// use the stop channel to stop all running websites +func (p *Project) RunWebsitesWithCommand(localCloud *cloud.LocalCloud, stop <-chan bool, updates chan<- ServiceRunUpdate, env map[string]string) error { + stopChannels := lo.FanOut[bool](len(p.websites), 1, stop) + + group, _ := errgroup.WithContext(context.TODO()) + + for i, site := range p.websites { + idx := i + s := site + + // start the service with the given file reference from its projects CWD + group.Go(func() error { + envVariables := map[string]string{ + "PYTHONUNBUFFERED": "TRUE", // ensure all print statements print immediately for python + "NITRIC_ENVIRONMENT": "run", + } + + for key, value := range env { + envVariables[key] = value + } + + err := s.Run(stopChannels[idx], updates, envVariables) + if err != nil { + return fmt.Errorf("%s: %w", s.Name, err) + } + + return nil + }) + } + + return group.Wait() +} + func (pc *ProjectConfiguration) pathToNormalizedServiceName(servicePath string) string { // Add the project name as a prefix to group service images servicePath = fmt.Sprintf("%s_%s", pc.Name, servicePath) @@ -758,6 +791,7 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig * basedir: websiteSpec.GetBasedir(), outputPath: websiteSpec.Build.Output, buildCmd: websiteSpec.Build.Command, + devCmd: websiteSpec.Dev.Command, indexPage: websiteSpec.IndexPage, errorPage: websiteSpec.ErrorPage, }) diff --git a/pkg/project/website.go b/pkg/project/website.go index c039a3b32..b079fa366 100644 --- a/pkg/project/website.go +++ b/pkg/project/website.go @@ -22,6 +22,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" ) type Website struct { @@ -33,6 +34,9 @@ type Website struct { // the build command to build the website buildCmd string + // the dev command to run the website + devCmd string + // the path to the website source files outputPath string @@ -51,9 +55,85 @@ func (s *Website) GetAbsoluteOutputPath() (string, error) { return filepath.Abs(s.GetOutputPath()) } -// Run - runs the website using the provided command. TODO +// Run - runs the website using the provided dev command func (s *Website) Run(stop <-chan bool, updates chan<- ServiceRunUpdate, env map[string]string) error { - return nil + if s.devCmd == "" { + return fmt.Errorf("no dev command provided for website %s", s.basedir) + } + + commandParts := strings.Split(s.devCmd, " ") + cmd := exec.Command( + commandParts[0], + commandParts[1:]..., + ) + + cmd.Env = append([]string{}, os.Environ()...) + cmd.Dir = s.basedir + + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + cmd.Stdout = &ServiceRunUpdateWriter{ + updates: updates, + serviceName: s.Name, + label: s.Name, + status: ServiceRunStatus_Running, + } + + cmd.Stderr = &ServiceRunUpdateWriter{ + updates: updates, + serviceName: s.Name, + label: s.Name, + status: ServiceRunStatus_Error, + } + + errChan := make(chan error) + + go func() { + err := cmd.Start() + if err != nil { + errChan <- fmt.Errorf("error starting website %s: %w", s.Name, err) + } else { + updates <- ServiceRunUpdate{ + ServiceName: s.Name, + Label: "nitric", + Status: ServiceRunStatus_Running, + Message: fmt.Sprintf("started website %s", s.Name), + } + } + + err = cmd.Wait() + if err != nil { + // provide runtime errors as a run update rather than as a fatal error + updates <- ServiceRunUpdate{ + ServiceName: s.Name, + Label: "nitric", + Status: ServiceRunStatus_Error, + Err: err, + } + } + + errChan <- nil + }() + + go func(cmd *exec.Cmd) { + <-stop + + err := cmd.Process.Signal(syscall.SIGTERM) + if err != nil { + _ = cmd.Process.Kill() + } + }(cmd) + + err := <-errChan + updates <- ServiceRunUpdate{ + ServiceName: s.Name, + Status: ServiceRunStatus_Error, + Err: err, + } + + return err } // Build - builds the website using the provided command @@ -112,15 +192,6 @@ func (s *Website) Build(updates chan ServiceBuildUpdate, env map[string]string) errChan <- nil }() - // go func(cmd *exec.Cmd) { - // <-stop - - // err := cmd.Process.Signal(syscall.SIGTERM) - // if err != nil { - // _ = cmd.Process.Kill() - // } - // }(cmd) - err := <-errChan if err != nil { @@ -129,12 +200,6 @@ func (s *Website) Build(updates chan ServiceBuildUpdate, env map[string]string) Status: ServiceBuildStatus_Error, Err: err, } - } else { - // updates <- ServiceBuildUpdate{ - // ServiceName: s.Name, - // Status: ServiceBuildStatus_Complete, - // Message: fmt.Sprintf("website %s built successfully", s.Name), - // } } return err From 85062fd3af9c2f09f4f80fd040218e4af20c0c6d Mon Sep 17 00:00:00 2001 From: David Moore Date: Thu, 23 Jan 2025 16:24:32 +1100 Subject: [PATCH 03/31] nitric run - single entrypoint with api proxy --- cmd/stack.go | 17 ++++ pkg/cloud/cloud.go | 2 +- pkg/cloud/websites/websites.go | 139 +++++++++++++++++++++++++-------- pkg/project/config.go | 2 + pkg/project/project.go | 42 +++++++--- pkg/project/website.go | 3 + 6 files changed, 162 insertions(+), 43 deletions(-) diff --git a/cmd/stack.go b/cmd/stack.go index 276394605..221ea99cf 100644 --- a/cmd/stack.go +++ b/cmd/stack.go @@ -274,6 +274,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/pkg/cloud/cloud.go b/pkg/cloud/cloud.go index a56213a7e..397fd3db4 100644 --- a/pkg/cloud/cloud.go +++ b/pkg/cloud/cloud.go @@ -317,7 +317,7 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { return nil, err } - localWebsites := websites.NewLocalWebsitesService() + localWebsites := websites.NewLocalWebsitesService(localGateway.GetApiAddress) return &LocalCloud{ servers: make(map[string]*server.NitricServer), diff --git a/pkg/cloud/websites/websites.go b/pkg/cloud/websites/websites.go index ca58b27e8..73a1c6aa3 100644 --- a/pkg/cloud/websites/websites.go +++ b/pkg/cloud/websites/websites.go @@ -21,21 +21,36 @@ import ( "maps" "net" "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" "sync" "github.com/asaskevich/EventBus" "github.com/nitrictech/cli/pkg/netx" - "github.com/nitrictech/nitric/core/pkg/logger" ) +type Website struct { + Name string + BaseRoute string + OutputDir string + IndexPage string + ErrorPage string +} + type ( - WebsiteName = string - State = map[WebsiteName]string + WebsiteName = string + State = map[WebsiteName]string + GetApiAddress = func(apiName string) string ) type LocalWebsiteService struct { websiteRegLock sync.RWMutex state State + port int + getApiAddress GetApiAddress bus EventBus.Bus } @@ -52,16 +67,11 @@ func (l *LocalWebsiteService) SubscribeToState(fn func(State)) { } // register - Register a new website -func (l *LocalWebsiteService) register(websiteName string, port int) { - if _, exists := l.state[websiteName]; exists { - logger.Warnf("Website %s is already registered", websiteName) - return - } - +func (l *LocalWebsiteService) register(website Website) { l.websiteRegLock.Lock() defer l.websiteRegLock.Unlock() - l.state[websiteName] = fmt.Sprintf("http://localhost:%d", port) + l.state[website.Name] = fmt.Sprintf("http://localhost:%d/%s", l.port, strings.TrimPrefix(website.BaseRoute, "/")) l.publishState() } @@ -76,45 +86,110 @@ func (l *LocalWebsiteService) deregister(websiteName string) { l.publishState() } -// Serve - Serve a website from the local filesystem -func (l *LocalWebsiteService) Serve(websiteName string, path string) error { - // serve the website from path using http server - fs := http.FileServer(http.Dir(path)) +type staticSiteHandler struct { + website Website + port int +} - // Create a new ServeMux to handle the request - mux := http.NewServeMux() - mux.Handle("/", fs) +// ServeHTTP - Serve a static website from the local filesystem +func (h staticSiteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := filepath.Join(h.website.OutputDir, r.URL.Path) - // get an available port - ports, err := netx.TakePort(1) + // check whether a file exists or is a directory at the given path + fi, err := os.Stat(path) if err != nil { - return err + if os.IsNotExist(err) { + // if the file doesn't exist, serve the error page with a 404 status code + http.ServeFile(w, r, filepath.Join(h.website.OutputDir, h.website.ErrorPage)) + return + } + + // if we got an error (that wasn't that the file doesn't exist) stating the + // file, return a 500 internal server error and stop + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if fi.IsDir() { + http.ServeFile(w, r, filepath.Join(h.website.OutputDir, h.website.IndexPage)) + return } - port := ports[0] // Take the first available port + // otherwise, use http.FileServer to serve the static file + http.FileServer(http.Dir(h.website.OutputDir)).ServeHTTP(w, r) +} - // Start the HTTP server on the assigned port - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) +// Serve - Serve a website from the local filesystem +func (l *LocalWebsiteService) Start(websites []Website) error { + newLis, err := netx.GetNextListener(netx.MinPort(5000)) if err != nil { - l.deregister(websiteName) - return fmt.Errorf("failed to start server on port %d: %w", port, err) + return err } + l.port = newLis.Addr().(*net.TCPAddr).Port + + _ = newLis.Close() + + // Initialize the multiplexer only if websites will be served + mux := http.NewServeMux() + + // Register the API handler + mux.HandleFunc("/api/{name}/", func(w http.ResponseWriter, r *http.Request) { + // get the api name from the request path + apiName := r.PathValue("name") + + // get the address of the api + apiAddress := l.getApiAddress(apiName) + if apiAddress == "" { + http.Error(w, fmt.Sprintf("api %s not found", apiName), http.StatusNotFound) + return + } + + // Strip /api/{name}/ from the URL path + newPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("/api/%s", apiName)) + + // Target backend API server + target, _ := url.Parse(apiAddress) + + // Reverse proxy request + proxy := httputil.NewSingleHostReverseProxy(target) + r.URL.Path = newPath + + // Forward the modified request to the backend + proxy.ServeHTTP(w, r) + }) + + // Register the SPA handler for each website + for _, website := range websites { + spa := staticSiteHandler{website: website, port: l.port} + + if website.BaseRoute == "/" { + mux.Handle("/", spa) + } else { + mux.Handle(website.BaseRoute+"/", http.StripPrefix(website.BaseRoute+"/", spa)) + } + } + + // Start the server with the multiplexer go func() { - if err := http.Serve(listener, mux); err != nil { - logger.Errorf("Error serving website %s: %s", websiteName, err.Error()) - l.deregister(websiteName) + addr := fmt.Sprintf(":%d", l.port) + if err := http.ListenAndServe(addr, mux); err != nil { + fmt.Printf("Failed to start server: %s\n", err) } }() - l.register(websiteName, port) + // Register the websites + for _, website := range websites { + l.register(website) + } return nil } -func NewLocalWebsitesService() *LocalWebsiteService { +func NewLocalWebsitesService(getApiAddress GetApiAddress) *LocalWebsiteService { return &LocalWebsiteService{ - state: State{}, - bus: EventBus.New(), + state: State{}, + bus: EventBus.New(), + getApiAddress: getApiAddress, } } diff --git a/pkg/project/config.go b/pkg/project/config.go index add5f58c0..ad50ea26b 100644 --- a/pkg/project/config.go +++ b/pkg/project/config.go @@ -91,6 +91,7 @@ type Build struct { type Dev struct { Command string `yaml:"command"` + Url string `yaml:"url,omitempty"` } type WebsiteConfiguration struct { @@ -98,6 +99,7 @@ type WebsiteConfiguration struct { Build Build `yaml:"build"` Dev Dev `yaml:"dev"` + Path string `yaml:"path"` IndexPage string `yaml:"index,omitempty"` ErrorPage string `yaml:"error,omitempty"` } diff --git a/pkg/project/project.go b/pkg/project/project.go index e2348ae34..82c225b4a 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -39,6 +39,7 @@ import ( goruntime "runtime" "github.com/nitrictech/cli/pkg/cloud" + "github.com/nitrictech/cli/pkg/cloud/websites" "github.com/nitrictech/cli/pkg/collector" "github.com/nitrictech/cli/pkg/preview" "github.com/nitrictech/cli/pkg/project/localconfig" @@ -588,22 +589,25 @@ func (p *Project) RunServices(localCloud *cloud.LocalCloud, stop <-chan bool, up // RunWebsites - Runs all the websites as http servers func (p *Project) RunWebsites(localCloud *cloud.LocalCloud) error { - group, _ := errgroup.WithContext(context.TODO()) + sites := []websites.Website{} + // register websites with the local cloud for _, site := range p.websites { - s := site - - group.Go(func() error { - absoluteOutputPath, err := s.GetAbsoluteOutputPath() - if err != nil { - return err - } + outputDir, err := site.GetAbsoluteOutputPath() + if err != nil { + return fmt.Errorf("unable to get absolute output path for website %s: %w", site.basedir, err) + } - return localCloud.Websites.Serve(s.Name, absoluteOutputPath) + sites = append(sites, websites.Website{ + Name: site.Name, + BaseRoute: site.path, + OutputDir: outputDir, + IndexPage: site.indexPage, + ErrorPage: site.errorPage, }) } - return group.Wait() + return localCloud.Websites.Start(sites) } // RunWebsitesWithCommand - Runs all the websites using a startup command @@ -774,6 +778,10 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig * return nil, fmt.Errorf("no build output provided for website %s", websiteSpec.GetBasedir()) } + if websiteSpec.Path == "" { + websiteSpec.Path = "/" // default to root path + } + if websiteSpec.IndexPage == "" { websiteSpec.IndexPage = "index.html" } @@ -789,6 +797,7 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig * websites = append(websites, Website{ Name: websiteName, basedir: websiteSpec.GetBasedir(), + path: websiteSpec.Path, outputPath: websiteSpec.Build.Output, buildCmd: websiteSpec.Build.Command, devCmd: websiteSpec.Dev.Command, @@ -797,6 +806,19 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig * }) } + // check for duplicate paths in websites and error + siteDuplicates := lo.FindDuplicatesBy(websites, func(website Website) string { + return website.path + }) + + if len(siteDuplicates) > 0 { + duplicatePaths := lo.Map(siteDuplicates, func(website Website, i int) string { + return website.path + }) + + return nil, fmt.Errorf("duplicate website paths found: %s", strings.Join(duplicatePaths, ", ")) + } + // create an empty local configuration if none is provided if localConfig == nil { localConfig = &localconfig.LocalConfiguration{} diff --git a/pkg/project/website.go b/pkg/project/website.go index b079fa366..281d7dc93 100644 --- a/pkg/project/website.go +++ b/pkg/project/website.go @@ -31,6 +31,9 @@ type Website struct { // the base directory for the website source files basedir string + // the path for the website subroutes, / is the root + path string + // the build command to build the website buildCmd string From 2269b4f1263f00e39f022d5354a9cec6808d39ea Mon Sep 17 00:00:00 2001 From: David Moore Date: Fri, 31 Jan 2025 10:45:12 +1100 Subject: [PATCH 04/31] add to spec --- cmd/debug.go | 5 ++++- cmd/stack.go | 7 +++++-- go.mod | 10 ++++----- go.sum | 28 ++++++++++++++----------- pkg/cloud/websites/websites.go | 25 +++++++++++----------- pkg/collector/spec.go | 22 +++++++++++++++++++- pkg/project/project.go | 38 ++++++++++++++++++++++++++++------ 7 files changed, 96 insertions(+), 39 deletions(-) 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/stack.go b/cmd/stack.go index 221ea99cf..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) diff --git a/go.mod b/go.mod index 2c0dff4a6..3e02fb68c 100644 --- a/go.mod +++ b/go.mod @@ -22,12 +22,12 @@ 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-20250123074029-0306df1e20ae 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 @@ -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 @@ -281,12 +281,12 @@ require ( go.uber.org/zap v1.24.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect - golang.org/x/net v0.28.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/term v0.27.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.27.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..f3aaaafb1 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.0 h1:vDfG60vDtIuf0MEOhmLlLLSzqaRM8EMcgJP 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= @@ -205,8 +205,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 +585,12 @@ 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-20250123065014-599bda2a2582 h1:dKVFR/rquvNB/FNu8GFAZJ5RcW7HrgyE+I8rdmvKwYc= +github.com/nitrictech/nitric/core v0.0.0-20250123065014-599bda2a2582/go.mod h1:3kPpyO2oZEGfurDVsTh9XTg43b/4JAIbLkaktEvRF58= +github.com/nitrictech/nitric/core v0.0.0-20250123070044-4b31d7498e96 h1:GdRQEkMYrZehM2SKkUecR2/SPa5TuKpxuuZetShZnAI= +github.com/nitrictech/nitric/core v0.0.0-20250123070044-4b31d7498e96/go.mod h1:3kPpyO2oZEGfurDVsTh9XTg43b/4JAIbLkaktEvRF58= +github.com/nitrictech/nitric/core v0.0.0-20250123074029-0306df1e20ae h1:hpSGt8KQ4OPPLqGvf5sFTv7h9isngWQLveVcfW4Z5i4= +github.com/nitrictech/nitric/core v0.0.0-20250123074029-0306df1e20ae/go.mod h1:3kPpyO2oZEGfurDVsTh9XTg43b/4JAIbLkaktEvRF58= 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= @@ -897,8 +901,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 +944,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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 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= @@ -1115,8 +1119,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.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= +golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= 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= diff --git a/pkg/cloud/websites/websites.go b/pkg/cloud/websites/websites.go index 73a1c6aa3..5e8b8d772 100644 --- a/pkg/cloud/websites/websites.go +++ b/pkg/cloud/websites/websites.go @@ -30,14 +30,15 @@ import ( "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 { - Name string - BaseRoute string - OutputDir string - IndexPage string - ErrorPage string + WebsitePb + + Name string } type ( @@ -71,7 +72,7 @@ func (l *LocalWebsiteService) register(website Website) { l.websiteRegLock.Lock() defer l.websiteRegLock.Unlock() - l.state[website.Name] = fmt.Sprintf("http://localhost:%d/%s", l.port, strings.TrimPrefix(website.BaseRoute, "/")) + l.state[website.Name] = fmt.Sprintf("http://localhost:%d/%s", l.port, strings.TrimPrefix(website.BasePath, "/")) l.publishState() } @@ -93,14 +94,14 @@ type staticSiteHandler struct { // ServeHTTP - Serve a static website from the local filesystem func (h staticSiteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - path := filepath.Join(h.website.OutputDir, r.URL.Path) + path := filepath.Join(h.website.OutputDirectory, r.URL.Path) // check whether a file exists or is a directory at the given 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(w, r, filepath.Join(h.website.OutputDir, h.website.ErrorPage)) + http.ServeFile(w, r, filepath.Join(h.website.OutputDirectory, h.website.ErrorDocument)) return } @@ -111,12 +112,12 @@ func (h staticSiteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if fi.IsDir() { - http.ServeFile(w, r, filepath.Join(h.website.OutputDir, h.website.IndexPage)) + http.ServeFile(w, r, filepath.Join(h.website.OutputDirectory, h.website.IndexDocument)) return } // otherwise, use http.FileServer to serve the static file - http.FileServer(http.Dir(h.website.OutputDir)).ServeHTTP(w, r) + http.FileServer(http.Dir(h.website.OutputDirectory)).ServeHTTP(w, r) } // Serve - Serve a website from the local filesystem @@ -163,10 +164,10 @@ func (l *LocalWebsiteService) Start(websites []Website) error { for _, website := range websites { spa := staticSiteHandler{website: website, port: l.port} - if website.BaseRoute == "/" { + if website.BasePath == "/" { mux.Handle("/", spa) } else { - mux.Handle(website.BaseRoute+"/", http.StripPrefix(website.BaseRoute+"/", spa)) + mux.Handle(website.BasePath+"/", http.StripPrefix(website.BasePath+"/", spa)) } } diff --git a/pkg/collector/spec.go b/pkg/collector/spec.go index e094e4d65..2441b9534 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,24 @@ func ServiceRequirementsToSpec(projectName string, environmentVariables map[stri }) } + for _, website := range websiteRequirements { + cleanedPath := strings.TrimRight(website.OutputDirectory, 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/project/project.go b/pkg/project/project.go index 82c225b4a..7438a41b3 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -45,6 +45,7 @@ import ( "github.com/nitrictech/cli/pkg/project/localconfig" "github.com/nitrictech/cli/pkg/project/runtime" "github.com/nitrictech/nitric/core/pkg/logger" + deploymentpb "github.com/nitrictech/nitric/core/pkg/proto/deployments/v1" ) type Project struct { @@ -451,6 +452,27 @@ func (p *Project) CollectBatchRequirements() ([]*collector.BatchRequirements, er return allBatchRequirements, nil } +func (p *Project) CollectWebsiteRequirements() ([]*deploymentpb.Website, error) { + allWebsiteRequirements := []*deploymentpb.Website{} + + for _, site := range p.websites { + outputDir, err := site.GetAbsoluteOutputPath() + if err != nil { + return nil, fmt.Errorf("unable to get absolute output path for website %s: %w", site.basedir, err) + } + + allWebsiteRequirements = append(allWebsiteRequirements, &deploymentpb.Website{ + BasePath: site.path, + OutputDirectory: outputDir, + IndexDocument: site.indexPage, + ErrorDocument: site.errorPage, + }) + + } + + return allWebsiteRequirements, nil +} + // DefaultMigrationImage - Returns the default migration image name for the project // Also returns ok if image is required or not func (p *Project) DefaultMigrationImage(fs afero.Fs) (string, bool) { @@ -588,6 +610,7 @@ func (p *Project) RunServices(localCloud *cloud.LocalCloud, stop <-chan bool, up } // RunWebsites - Runs all the websites as http servers +// TODO this has duplicate code with CollectWebsiteRequirements func (p *Project) RunWebsites(localCloud *cloud.LocalCloud) error { sites := []websites.Website{} @@ -599,11 +622,13 @@ func (p *Project) RunWebsites(localCloud *cloud.LocalCloud) error { } sites = append(sites, websites.Website{ - Name: site.Name, - BaseRoute: site.path, - OutputDir: outputDir, - IndexPage: site.indexPage, - ErrorPage: site.errorPage, + Name: site.Name, + WebsitePb: websites.WebsitePb{ + BasePath: site.outputPath, + OutputDirectory: outputDir, + IndexDocument: site.indexPage, + ErrorDocument: site.errorPage, + }, }) } @@ -778,8 +803,9 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig * return nil, fmt.Errorf("no build output provided for website %s", websiteSpec.GetBasedir()) } + // apply defaults if websiteSpec.Path == "" { - websiteSpec.Path = "/" // default to root path + websiteSpec.Path = "/" } if websiteSpec.IndexPage == "" { From 6cfcf3c89e718877ae14da4e99b39821ed0bb9c3 Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 11 Feb 2025 16:51:58 +1100 Subject: [PATCH 05/31] websites in dashboard wip --- pkg/cloud/websites/websites.go | 5 +- pkg/dashboard/dashboard.go | 29 +++ .../components/layout/AppLayout/AppLayout.tsx | 6 + .../src/components/websites/SiteExplorer.tsx | 171 ++++++++++++++++++ .../src/components/websites/SiteTreeView.tsx | 64 +++++++ .../frontend/src/pages/websites.astro | 8 + pkg/dashboard/frontend/src/types.ts | 6 + pkg/project/project.go | 2 +- 8 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 pkg/dashboard/frontend/src/components/websites/SiteExplorer.tsx create mode 100644 pkg/dashboard/frontend/src/components/websites/SiteTreeView.tsx create mode 100644 pkg/dashboard/frontend/src/pages/websites.astro diff --git a/pkg/cloud/websites/websites.go b/pkg/cloud/websites/websites.go index 5e8b8d772..b83f9f33a 100644 --- a/pkg/cloud/websites/websites.go +++ b/pkg/cloud/websites/websites.go @@ -88,7 +88,7 @@ func (l *LocalWebsiteService) deregister(websiteName string) { } type staticSiteHandler struct { - website Website + website *Website port int } @@ -161,7 +161,8 @@ func (l *LocalWebsiteService) Start(websites []Website) error { }) // Register the SPA handler for each website - for _, website := range websites { + for i := range websites { + website := &websites[i] spa := staticSiteHandler{website: website, port: l.port} if website.BasePath == "/" { diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index 0f6061435..902c4e547 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,6 +157,11 @@ type HttpProxySpec struct { Target string `json:"target"` } +type WebsiteSpec struct { + Name string `json:"name"` + URL string `json:"url"` +} + type Dashboard struct { resourcesLock sync.Mutex project *project.Project @@ -174,6 +180,7 @@ type Dashboard struct { secrets []*SecretSpec sqlDatabases []*SQLDatabaseSpec websockets []WebsocketSpec + websites []WebsiteSpec subscriptions []*SubscriberSpec notifications []*NotifierSpec httpProxies []*HttpProxySpec @@ -201,6 +208,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"` @@ -630,6 +638,24 @@ 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, url := range state { + websites = append(websites, WebsiteSpec{ + Name: strings.TrimPrefix(name, "websites_"), + URL: url, + }) + } + + d.websites = websites + + d.refresh() +} + func (d *Dashboard) refresh() { if !d.noBrowser && !d.browserHasOpened { d.openBrowser() @@ -826,6 +852,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, @@ -913,6 +940,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 +971,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/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..d34a04350 --- /dev/null +++ b/pkg/dashboard/frontend/src/components/websites/SiteExplorer.tsx @@ -0,0 +1,171 @@ +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' + +const LOCAL_STORAGE_KEY = 'nitric-local-dash-storage-history' + +const SiteExplorer = () => { + const [selectedWebsite, setSelectedWebsite] = useState() + const { data, loading } = useWebSocket() + + const { websites } = 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. + + )} +
+ +
+ + +
+
+
+
+ {selectedWebsite.url} +
+
+