commit 920634d11b5b4d5f4ff2e5964a97244df5a277b5 Author: Thomas Martin <2121321@stud.hs-mannheim.de> Date: Thu Nov 21 17:47:49 2024 +0100 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..4dabb93 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# cmg-ws202425 + +Cloud-native Microservices mit Go - WS 2024/25 + +# Green Compute Load Shifting (GCLS) + +![green compute load shifting](doc/overview.png) + +## Functional Requirements + +1. Consumers can create new computation jobs. A container image along with a set of environment variables defines the computation job. + +2. Consumers can get the results of a compute job. The result consists of the state, the standard output produced by the job, and an estimate of the amount of CO2 equivalent that has been been emitted while executing the job. + +3. Providers can offer computing capabilities and run assigned jobs + +4. Jobs are scheduled to minimize runtime and carbon footprint + +## Quality Requirements - Functional Suitability + +### Functional Correctness + +The error margin for estimating carbon emission shall be $\frac{+}{-} 10\%$ + +### Functional Appropriateness + +In each week, the system shall reduce the carbon emissions of running compute jobs by at least $50\%$ compared to running the jobs in the client region + +## Quality Requirements - Performance Efficiency + +### Time behavior + +$95\%$ of all backend requests that originate from a user must be handled in less than $50 ms$ + +### Capacity + +The system must be able to handle up to 10000 jobs per day. + +### Resource utilization + +- The CPU and Memory consumption of all jobs running on a worker must not exceed $50\%$ of the available resources of that worker. + +- **The overall cloud provider costs must not exceed the value of the obtained Google Cloud Education Credits** + +## Quality Requirements - Compatibility + +### Interoperability + +The system must support Docker container images that are available on [the docker hub](https://hub.docker.com/) + +## Quality Requirements - Reliability + +### Availability + +In each 24h timeframe, the request success rate must be at least $95\%$ + +### Fault tolerance + +The system must not reject requests in case the carbon intensity provider is temporarily not available. + +## Quality Requirements - Security + +### Authenticity + +All inter service communication must be authenticated and authorized (Zero Trust) + +### Confidentiality + +- Compute Consumers and Compute Providers must ony be able to access job information assigned to them + +## Quality Requirements - Maintainability + +### Reusability + +Cross cutting concerns shall be implemented consistently in all services + +### Testability + +- The business code must be testable in isolation +- The unit test coverage of the business code must be at least $80\%$ + +## Quality Requirements - Flexibility + +### Replaceability + +- The cloud provider must be replaceable without changing the business code +- The carbon intensity provider must be replaceable without redeploying more than one service + +## Boundary Conditions - Architecure + +1. The predefined microservices architecture must be followed (see next slides) +2. All services/clients must be implemented using the Go Programming Language +3. Each microservice needs to adhere to the [ports & adapters](https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)) structure +4. All services need to be stateless +5. All synchronous communication shall be handled using REST APIs +6. APIs shall be idempotent, breaking changes shall be avoided +7. Each service shall be containerized + +## Boundary Conditions - Development + +1. A single monorepo shall be used for all code +2. All Infrastructure shall be defined as code (IaC) using [terraform](https://www.terraform.io/) +3. All features must be implemented in short lived feature branches +4. The usage of 3rd party packages (other than from the Go standard library) must be approved +5. All PRs must be reviewed by at least one other team member before a feature branch can be merged to main +6. All tests must be passed before a feature branch can be merged to main + +## Boundary Conditions - Security + +1. JWTs shall be used for all requests originating from a client +2. basic auth shall be used for all other communication +3. secrets must not be stored in the repository + +## Boundary Conditions - Deployment + +1. The entrire system shall be deployed to the [Google cloud platform](https://console.cloud.google.com/). The gcp project id is `cmg-ws202425`. +2. **The overall provider costs must not exceed the value of the obtained Google Cloud Education Credits** +3. All services shall be deployed to a CaaS or PaaS offering in a single GCP cloud environment +4. Each service has its own CI/CD pipeline +5. Deployments are done independetly for each service with zero downtime (Rolling updates) + +## Boundary Conditions - Operations + +1. All logs shall be written to standard output +2. Business, Application, and Infrastructure level metrics shall be collected by [prometheus](https://prometheus.io/). +3. All requests shall be traced using [jaeger](https://www.jaegertracing.io/) +4. each service must provide health probes for readiness and liveness +5. each service must terminate upon receiving the SIGTERM signal within 2 seconds. \ No newline at end of file diff --git a/cli/consumer-cli/README.md b/cli/consumer-cli/README.md new file mode 100644 index 0000000..d1c3700 --- /dev/null +++ b/cli/consumer-cli/README.md @@ -0,0 +1,13 @@ +# Entity Service + +The entity service is an example service that demonstrates the folder structure of a microservice following the ports & adapters architecture. + +> **WARNING** +> The implementation is in an early stage. Many things are still missing. Use with care. + +## Usage + +```bash +curl -X PUT -d '{ "Id": "34", "IntProp" : 23, "StringProp": "test" }' localhost:8080/entity +curl localhost:8080/entity/34 +``` diff --git a/cli/consumer-cli/adapter/jwt_store/jwt-store.go b/cli/consumer-cli/adapter/jwt_store/jwt-store.go new file mode 100644 index 0000000..4db3201 --- /dev/null +++ b/cli/consumer-cli/adapter/jwt_store/jwt-store.go @@ -0,0 +1,33 @@ +package jwt_store + +import ( + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +type JWTStore struct { + //Gespeichert als Bytearray um manuelles überschreieben zu ermöglichen + jwt []byte +} + +func NewJWTStore() *JWTStore { + return &JWTStore{} +} + +func (s *JWTStore) GetJWT() string { + return string(s.jwt) +} + +func (s *JWTStore) SetJWT(jwt string) { + s.jwt = []byte(jwt) +} + +func (s *JWTStore) DeleteJWT() { + + for i := range s.jwt { + s.jwt[i] = 0 + } + + s.jwt = nil +} + +var _ ports.JWTStore = (*JWTStore)(nil) // Check if the Store struct implements the Communication interface diff --git a/cli/consumer-cli/adapter/jwt_store/jwt-store_test.go b/cli/consumer-cli/adapter/jwt_store/jwt-store_test.go new file mode 100644 index 0000000..68d5fda --- /dev/null +++ b/cli/consumer-cli/adapter/jwt_store/jwt-store_test.go @@ -0,0 +1,25 @@ +package jwt_store + +import ( + "testing" +) + +func TestJWTStore(t *testing.T) { + store := NewJWTStore() + + if store.GetJWT() != "" { + t.Error("Expected empty jwt, but got", store.GetJWT()) + } + + store.SetJWT("mySecret") + + if store.GetJWT() != "mySecret" { + t.Error("Expected jwt to be set, but got", store.GetJWT()) + } + + store.DeleteJWT() + + if store.GetJWT() != "" { + t.Error("Expected jwt to be deleted, but got", store.GetJWT()) + } +} diff --git a/cli/consumer-cli/adapter/mocks/rest_client_mock/rest-client-mock.go b/cli/consumer-cli/adapter/mocks/rest_client_mock/rest-client-mock.go new file mode 100644 index 0000000..3be7dfd --- /dev/null +++ b/cli/consumer-cli/adapter/mocks/rest_client_mock/rest-client-mock.go @@ -0,0 +1,76 @@ +package rest_client_mock + +import ( + "fmt" + + "github.com/google/uuid" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +type MockRestClient struct { + jobs map[uuid.UUID]ports.GetJobDto +} + +func NewMockRestClient() *MockRestClient { + return &MockRestClient{jobs: make(map[uuid.UUID]ports.GetJobDto)} +} + +func (d *MockRestClient) GetJob(id string) (*ports.GetJobDto, error) { + val, ok := d.jobs[uuid.MustParse(id)] + if !ok { + return nil, fmt.Errorf("job %s not found", id) + } + return &val, nil +} + +func (d *MockRestClient) GetJobs() ([]ports.GetJobDto, error) { + jobs := []ports.GetJobDto{} + for _, v := range d.jobs { + jobs = append(jobs, v) + } + return jobs, nil +} + +func (d *MockRestClient) CreateJobDto(dto ports.CreateJobDto) error { + job := dto.Job + id := uuid.New() + d.jobs[id] = ports.GetJobDto{ + Id: id.String(), + Name: job.Name, + ConsumerId: "", + ImageName: job.ImageName, + EnvironmentVariables: job.EnvironmentVariables, + Status: "", + StandardOutput: "", + CreatedAt: 0, + StartedAt: 0, + FinishedAt: 0, + ConsumerLongitude: job.ConsumerLongitude, + ConsumerLatitude: job.ConsumerLatitude, + Co2EquivalentEmissionConsumer: 0, + EstimatedCo2Equivalent: 0, + } + return nil +} + +func (d *MockRestClient) Login(dto ports.LoginDto) (*ports.LoginResponseDto, error) { + return &ports.LoginResponseDto{ + Token: "aaaaaa", + }, nil +} + +type MockLocationStore struct { + geolocation *ports.Geolocation +} + +func (d *MockLocationStore) GetLocation() (*ports.Geolocation, error) { + if d.geolocation == nil { + return nil, fmt.Errorf("no geolocation store") + } + return d.geolocation, nil +} + +func (d *MockLocationStore) SetLocation(latitude float64, longitude float64) error { + d.geolocation = &ports.Geolocation{Latitude: latitude, Longitude: longitude} + return nil +} diff --git a/cli/consumer-cli/adapter/rest_client/rest-client.go b/cli/consumer-cli/adapter/rest_client/rest-client.go new file mode 100644 index 0000000..8cfc222 --- /dev/null +++ b/cli/consumer-cli/adapter/rest_client/rest-client.go @@ -0,0 +1,117 @@ +package rest_client + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +const serverPort = 8080 + +var requestURL = fmt.Sprintf("http://localhost:%d/", serverPort) + +type RestClient struct { +} + +func NewRestClient() *RestClient { + h := RestClient{} + return &h +} + +func (r *RestClient) GetJob(id string) (*ports.GetJobDto, error) { + var data *ports.GetJobDto + data = &ports.GetJobDto{} + + res, err := http.Get(requestURL + "job/" + id) + if err != nil { + fmt.Printf("Error making http request: %s\n", err) + return nil, err + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + fmt.Println("Error reading Response Body:", err) + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + fmt.Println("Error parsing JSON:", err) + return nil, err + } + return data, nil +} + +func (r *RestClient) GetJobs() ([]ports.GetJobDto, error) { + var data []ports.GetJobDto + + res, err := http.Get(requestURL + "job") + if err != nil { + fmt.Printf("Error making http request: %s\n", err) + return nil, err + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + fmt.Println("Error reading Response Body:", err) + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + fmt.Println("Error parsing JSON:", err) + return nil, err + } + return data, nil +} + +func (r *RestClient) CreateJobDto(dto ports.CreateJobDto) error { + jsonBody, err := json.Marshal(dto) + res, err := http.Post(requestURL+"job", "application/json", bytes.NewBuffer(jsonBody)) + if err != nil { + fmt.Printf("Error making http request: %s\n", err) + return err + } + if res.StatusCode != http.StatusOK { + fmt.Printf("Error making http request: %s\n", res.Status) + return err + } + fmt.Println("Success") + return nil +} + +func (r *RestClient) Login(dto ports.LoginDto) (*ports.LoginResponseDto, error) { + var data *ports.LoginResponseDto + data = &ports.LoginResponseDto{} + + jsonBody, err := json.Marshal(dto) + res, err := http.Post(requestURL+"login", "application/json", bytes.NewBuffer(jsonBody)) + if err != nil { + fmt.Printf("Error making http request: %s\n", err) + return nil, err + } + if res.StatusCode != http.StatusOK { + fmt.Printf("Error making http request: %s\n", res.Status) + return nil, err + } + + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + fmt.Println("Error reading Response Body:", err) + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + fmt.Println("Error parsing JSON:", err) + return nil, err + } + return data, nil +} + +var _ ports.RestClient = (*RestClient)(nil) // Check if the Store struct implements the Communication interface diff --git a/cli/consumer-cli/core/cli/cli.go b/cli/consumer-cli/core/cli/cli.go new file mode 100644 index 0000000..6428e15 --- /dev/null +++ b/cli/consumer-cli/core/cli/cli.go @@ -0,0 +1,109 @@ +package core + +import ( + "fmt" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +type CLI struct { + commandHandler ports.Api +} + +func NewCli(commandHandler ports.Api) *CLI { + return &CLI{commandHandler: commandHandler} +} + +func (c *CLI) Scan(input string) { + commandParser := CommandParser{} + command, err := commandParser.ParseCommand(input) + if err != nil { + fmt.Printf("%s\n", err) + return + } + + switch cmd := command.(type) { + case *ports.CreateJobCommand: + c.onCreateJobCommand(*cmd) + case *ports.GetAllJobsCommand: + c.onGetAllJobsCommand(*cmd) + case *ports.GetJobByIdCommand: + c.onGetJobByIdCommand(*cmd) + case *ports.LoginCommand: + c.onLoginCommand(*cmd) + case *ports.GetConsumerLocationCommand: + c.onGetConsumerLocationCommand(*cmd) + case *ports.SetConsumerLocationCommand: + c.onSetConsumerLocationCommand(*cmd) + case *ports.HelpCommand: + c.onHelpCommand(*cmd) + default: + fmt.Println("Please specify a valid command") + } +} + +func (c *CLI) onCreateJobCommand(cmd ports.CreateJobCommand) { + result, err := c.commandHandler.HandleCreateJob(cmd) + if err != nil { + fmt.Printf("Error: %s\n", err) + } else { + fmt.Printf("Created Job: %+v\n", result) + } +} + +func (c *CLI) onGetAllJobsCommand(cmd ports.GetAllJobsCommand) { + result, err := c.commandHandler.HandleGetAllJobs(cmd) + if err != nil { + fmt.Printf("Error: %s\n", err) + } else { + for _, job := range result { + c.printJob(job) + } + } +} + +func (c *CLI) onGetJobByIdCommand(cmd ports.GetJobByIdCommand) { + result, err := c.commandHandler.HandleGetJobById(cmd) + if err != nil { + fmt.Printf("Error: %s\n", err) + } else { + c.printJob(*result) + } +} + +func (c *CLI) onLoginCommand(cmd ports.LoginCommand) { + _, err := c.commandHandler.HandleLogin(cmd) + if err != nil { + fmt.Printf("Error: %s\n", err) + } else { + fmt.Printf("Successfully loged in\n") + } +} + +func (c *CLI) onGetConsumerLocationCommand(cmd ports.GetConsumerLocationCommand) { + result, err := c.commandHandler.HandleGetConsumerLocation(cmd) + if err != nil { + fmt.Printf("Error: %s\n", err) + } else { + fmt.Printf("Latitude: %f\nLongitude: %f\n", result.Latitude, result.Longitude) + } +} + +func (c *CLI) onSetConsumerLocationCommand(cmd ports.SetConsumerLocationCommand) { + err := c.commandHandler.HandleSetConsumerLocation(cmd) + if err != nil { + fmt.Printf("Error: %s\n", err) + } else { + fmt.Println("Successfully set location") + } +} + +func (c *CLI) onHelpCommand(cmd ports.HelpCommand) { + result := c.commandHandler.HandleHelp(cmd) + fmt.Println(result) +} + +func (c *CLI) printJob(dto ports.GetJobDto) { + fmt.Printf("ID: %s\nName: %s\nImageName: %s\nEnviromental Variables: %s\nStatus: %s\nStandard Output: %s\nCreated at: %d\nStarted at: %d\nFinished at: %d\nConsumer Latitude: %f\nConsumer Longitude: %f\nCo2 Equivalent Emission Consumer: %f\nEstimated Co2 Equvaelnt: %f\n", dto.Id, dto.Name, dto.ImageName, dto.EnvironmentVariables, dto.Status, dto.StandardOutput, dto.CreatedAt, dto.StartedAt, dto.FinishedAt, dto.ConsumerLatitude, dto.ConsumerLongitude, dto.Co2EquivalentEmissionConsumer, dto.EstimatedCo2Equivalent) + fmt.Println() +} diff --git a/cli/consumer-cli/core/cli/cli_test.go b/cli/consumer-cli/core/cli/cli_test.go new file mode 100644 index 0000000..9ad0165 --- /dev/null +++ b/cli/consumer-cli/core/cli/cli_test.go @@ -0,0 +1,142 @@ +package core + +import ( + "errors" + "testing" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +type MockCommandHandler struct { + handleCreateJobFunc func(command ports.CreateJobCommand) (*ports.BaseJob, error) + handleGetJobByIdFunc func(command ports.GetJobByIdCommand) (*ports.GetJobDto, error) + handleGetAllJobsFunc func(command ports.GetAllJobsCommand) ([]ports.GetJobDto, error) + handleLoginFunc func(command ports.LoginCommand) (*ports.LoginResponseDto, error) + handleHelpFunc func(command ports.HelpCommand) string + handleSetConsumerLocationFunc func(command ports.SetConsumerLocationCommand) error + handleGetConsumerLocationFunc func(command ports.GetConsumerLocationCommand) (*ports.Geolocation, error) +} + +func (m *MockCommandHandler) HandleCreateJob(command ports.CreateJobCommand) (*ports.BaseJob, error) { + return m.handleCreateJobFunc(command) +} + +func (m *MockCommandHandler) HandleGetJobById(command ports.GetJobByIdCommand) (*ports.GetJobDto, error) { + return m.handleGetJobByIdFunc(command) +} + +func (m *MockCommandHandler) HandleGetAllJobs(command ports.GetAllJobsCommand) ([]ports.GetJobDto, error) { + return m.handleGetAllJobsFunc(command) +} + +func (m *MockCommandHandler) HandleLogin(command ports.LoginCommand) (*ports.LoginResponseDto, error) { + return m.handleLoginFunc(command) +} + +func (m *MockCommandHandler) HandleHelp(command ports.HelpCommand) string { + return m.handleHelpFunc(command) +} + +func (m *MockCommandHandler) HandleSetConsumerLocation(command ports.SetConsumerLocationCommand) error { + return m.handleSetConsumerLocationFunc(command) +} + +func (m *MockCommandHandler) HandleGetConsumerLocation(command ports.GetConsumerLocationCommand) (*ports.Geolocation, error) { + return m.handleGetConsumerLocationFunc(command) +} + +func TestCLI(t *testing.T) { + tests := []struct { + name string + input string + expectedFunction string + }{ + { + name: "Create Job Command", + input: `job create "myJob" "myImage" "env1:value1" "env2:value2"`, + expectedFunction: "HandleCreateJob", + }, + { + name: "Get All Jobs Command", + input: `job get`, + expectedFunction: "HandleGetAllJobs", + }, + { + name: "Get Job By ID Command", + input: `job get 123`, + expectedFunction: "HandleGetJobById", + }, + { + name: "Login Command", + input: `login "username" "password"`, + expectedFunction: "HandleLogin", + }, + { + name: "Get Consumer Location Command", + input: `location get`, + expectedFunction: "HandleGetConsumerLocation", + }, + { + name: "Set Consumer Location Command", + input: `location set 37.7749 -122.4194`, + expectedFunction: "HandleSetConsumerLocation", + }, + { + name: "Help Command", + input: `help`, + expectedFunction: "HandleHelp", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCommandHandler := &MockCommandHandler{ + handleCreateJobFunc: func(command ports.CreateJobCommand) (*ports.BaseJob, error) { + if tt.expectedFunction != "HandleCreateJob" { + t.Errorf("Unexpected function call: HandleCreateJob") + } + return nil, errors.New("not implemented") + }, + handleGetJobByIdFunc: func(command ports.GetJobByIdCommand) (*ports.GetJobDto, error) { + if tt.expectedFunction != "HandleGetJobById" { + t.Errorf("Unexpected function call: HandleGetJobById") + } + return nil, errors.New("not implemented") + }, + handleGetAllJobsFunc: func(command ports.GetAllJobsCommand) ([]ports.GetJobDto, error) { + if tt.expectedFunction != "HandleGetAllJobs" { + t.Errorf("Unexpected function call: HandleGetAllJobs") + } + return nil, errors.New("not implemented") + }, + handleLoginFunc: func(command ports.LoginCommand) (*ports.LoginResponseDto, error) { + if tt.expectedFunction != "HandleLogin" { + t.Errorf("Unexpected function call: HandleLogin") + } + return nil, errors.New("not implemented") + }, + handleHelpFunc: func(command ports.HelpCommand) string { + if tt.expectedFunction != "HandleHelp" { + t.Errorf("Unexpected function call: HandleHelp") + } + return "" + }, + handleSetConsumerLocationFunc: func(command ports.SetConsumerLocationCommand) error { + if tt.expectedFunction != "HandleSetConsumerLocation" { + t.Errorf("Unexpected function call: HandleSetConsumerLocation") + } + return errors.New("not implemented") + }, + handleGetConsumerLocationFunc: func(command ports.GetConsumerLocationCommand) (*ports.Geolocation, error) { + if tt.expectedFunction != "HandleGetConsumerLocation" { + t.Errorf("Unexpected function call: HandleGetConsumerLocation") + } + return nil, errors.New("not implemented") + }, + } + + cli := NewCli(mockCommandHandler) + cli.Scan(tt.input) + }) + } +} diff --git a/cli/consumer-cli/core/cli/command-handler.go b/cli/consumer-cli/core/cli/command-handler.go new file mode 100644 index 0000000..daf61ec --- /dev/null +++ b/cli/consumer-cli/core/cli/command-handler.go @@ -0,0 +1,106 @@ +package core + +import ( + "fmt" + + location_store "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/core/location-store" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +type CommandHandler struct { + restClient ports.RestClient + locationStore location_store.LocationStore +} + +var _ ports.Api = (*CommandHandler)(nil) + +func NewCommandHandler(restCl ports.RestClient, locationSt location_store.LocationStore) *CommandHandler { + return &CommandHandler{restClient: restCl, locationStore: locationSt} +} + +func (h *CommandHandler) HandleCreateJob(command ports.CreateJobCommand) (*ports.BaseJob, error) { + location, err := h.locationStore.GetLocation() + if err != nil { + return nil, fmt.Errorf("unable to read location: %w", err) + + } + + createJobDto := ports.CreateJobDto{ + Job: ports.BaseJob{ + Name: command.JobName, + ImageName: command.ImageName, + EnvironmentVariables: command.EnvironmentVariables, + ConsumerLongitude: location.Longitude, + ConsumerLatitude: location.Latitude, + }, + } + err = h.restClient.CreateJobDto(createJobDto) + if err != nil { + return nil, err + + } else { + return &createJobDto.Job, nil + + } + +} + +func (h *CommandHandler) HandleGetJobById(command ports.GetJobByIdCommand) (*ports.GetJobDto, error) { + dto, err := h.restClient.GetJob(command.ID) + if err != nil { + return nil, err + + } else { + return dto, nil + } + +} + +func (h *CommandHandler) HandleGetAllJobs(command ports.GetAllJobsCommand) ([]ports.GetJobDto, error) { + joblist, err := h.restClient.GetJobs() + if err != nil { + return nil, err + + } else { + return joblist, nil + } + +} + +func (h *CommandHandler) HandleLogin(command ports.LoginCommand) (*ports.LoginResponseDto, error) { + loginDto := ports.LoginDto{ + Credentials: ports.Credentials{ + Username: command.UserName, + Password: command.Password, + }, + } + loginResponse, err := h.restClient.Login(loginDto) + if err != nil { + return nil, err + + } else { + return loginResponse, nil + } + +} + +func (h *CommandHandler) HandleHelp(command ports.HelpCommand) string { + return "Test" + +} + +func (h *CommandHandler) HandleSetConsumerLocation(command ports.SetConsumerLocationCommand) error { + err := h.locationStore.SetLocation(command.Latitude, command.Longitude) + if err != nil { + return fmt.Errorf("unable to read Location: %w", err) + } + return nil +} + +func (h *CommandHandler) HandleGetConsumerLocation(command ports.GetConsumerLocationCommand) (*ports.Geolocation, error) { + geolocation, err := h.locationStore.GetLocation() + if err != nil { + return nil, fmt.Errorf("unable to read Location: %w", err) + } + return geolocation, nil +} diff --git a/cli/consumer-cli/core/cli/command-handler_test.go b/cli/consumer-cli/core/cli/command-handler_test.go new file mode 100644 index 0000000..3be868e --- /dev/null +++ b/cli/consumer-cli/core/cli/command-handler_test.go @@ -0,0 +1,100 @@ +package core + +import ( + "testing" + + rest_client_mock "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/adapter/mocks/rest_client_mock" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +func TestCommandHandler_HandleCreateJob(t *testing.T) { + handler := NewCommandHandler(rest_client_mock.NewMockRestClient(), &rest_client_mock.MockLocationStore{}) + + commandBeforeLocationSet := ports.CreateJobCommand{ + JobName: "", + ImageName: "", + EnvironmentVariables: nil, + } + a, err := handler.HandleCreateJob(commandBeforeLocationSet) + if err == nil { + t.Error("Expected error, got nil ", a) + } + + setLocationCmd := ports.SetConsumerLocationCommand{ + Longitude: 0, + Latitude: 0, + } + + err = handler.HandleSetConsumerLocation(setLocationCmd) + if err != nil { + t.Error(err) + } + + commandAfterLocationSet := ports.CreateJobCommand{ + JobName: "", + ImageName: "", + EnvironmentVariables: nil, + } + + _, err = handler.HandleCreateJob(commandAfterLocationSet) + if err != nil { + t.Error(err) + } + + jobs, err := handler.HandleGetAllJobs(ports.GetAllJobsCommand{}) + if len(jobs) != 1 { + t.Error("Expected 1 job, got ", len(jobs)) + } + id := jobs[0].Id + + job, err := handler.HandleGetJobById(ports.GetJobByIdCommand{ID: id}) + if job == nil { + t.Error("Expected job, got nil") + } +} + +func TestCommandHandler_HandleLogin(t *testing.T) { + handler := NewCommandHandler(rest_client_mock.NewMockRestClient(), &rest_client_mock.MockLocationStore{}) + + _, err := handler.HandleLogin(ports.LoginCommand{ + UserName: "a", + Password: "b", + }) + if err != nil { + t.Error(err) + } +} + +func TestCommandHandler_HandleLocation(t *testing.T) { + handler := NewCommandHandler(rest_client_mock.NewMockRestClient(), &rest_client_mock.MockLocationStore{}) + + _, err := handler.HandleGetConsumerLocation(ports.GetConsumerLocationCommand{}) + if err == nil { + t.Error("Expected 'get location' to fail ") + } + + err = handler.HandleSetConsumerLocation(ports.SetConsumerLocationCommand{ + Longitude: 10, + Latitude: -7, + }) + if err != nil { + t.Error(err) + } + + geo, err := handler.HandleGetConsumerLocation(ports.GetConsumerLocationCommand{}) + if err != nil { + t.Error(err) + } + if geo.Longitude != 10 || geo.Latitude != -7 { + t.Error("Wrong Coordinates") + } +} + +func TestCommandHandler_HandleHelp(t *testing.T) { + handler := NewCommandHandler(rest_client_mock.NewMockRestClient(), &rest_client_mock.MockLocationStore{}) + + help := handler.HandleHelp(ports.HelpCommand{}) + if help == "" { + t.Error("Expected help") + } +} diff --git a/cli/consumer-cli/core/cli/command-parser.go b/cli/consumer-cli/core/cli/command-parser.go new file mode 100644 index 0000000..291d7ef --- /dev/null +++ b/cli/consumer-cli/core/cli/command-parser.go @@ -0,0 +1,153 @@ +package core + +import ( + "errors" + "regexp" + "strconv" + "strings" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +type CommandParser struct { +} + +const JOB_COMMAND_IDENTIFIER = "job" +const JOB_CREATE_ACTION = "create" +const JOB_GET_ACTION = "get" + +const LOGIN_COMMAND_IDENTIFIER = "login" + +const LOCATION_COMMAND_IDENTIFIER = "location" +const LOCATION_GET_ACTION = "get" +const LOCATION_SET_ACTION = "set" + +const HELP_COMMAND_IDENTIFIER = "help" + +func (c *CommandParser) ParseCommand(command string) (ports.Command, error) { + command = strings.Replace(command, "\n", "", -1) + command = strings.TrimSpace(command) + + pattern := `"[^"]*"|\S+` + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(command, -1) + commandParts, err := flattenAndRemoveQuotes(matches) + if err != nil { + return nil, err + } + + if len(commandParts) == 0 { + return nil, errors.New("invalid command") + } + + commandIdentifier := commandParts[0] + switch commandIdentifier { + case JOB_COMMAND_IDENTIFIER: + return extractJobCommand(commandParts) + case LOGIN_COMMAND_IDENTIFIER: + return extractLoginCommand(commandParts) + case LOCATION_COMMAND_IDENTIFIER: + return extractLocationCommand(commandParts) + case HELP_COMMAND_IDENTIFIER: + return &ports.HelpCommand{}, nil + default: + return nil, errors.New("unkonwn command") + } +} + +func extractLocationCommand(commandParts []string) (ports.Command, error) { + if len(commandParts) <= 1 { + return nil, errors.New("invalid location command") + } + + action := commandParts[1] + switch action { + case LOCATION_GET_ACTION: + return &ports.GetConsumerLocationCommand{}, nil + case LOCATION_SET_ACTION: + if len(commandParts) != 4 { + return nil, errors.New("invalid set location command") + } + + latitude, err := strconv.ParseFloat(commandParts[2], 64) + if err != nil { + return nil, errors.New("invalid latitude") + } + longitude, err := strconv.ParseFloat(commandParts[3], 64) + if err != nil { + return nil, errors.New("invalid longitude") + } + + return &ports.SetConsumerLocationCommand{Longitude: longitude, Latitude: latitude}, nil + default: + return nil, errors.New("unknown location action") + } +} + +func extractLoginCommand(commandParts []string) (ports.Command, error) { + if len(commandParts) != 3 { + return nil, errors.New("invalid login command") + } + + return &ports.LoginCommand{UserName: commandParts[1], Password: commandParts[2]}, nil +} + +func extractJobCommand(commandParts []string) (ports.Command, error) { + commandPartsLen := len(commandParts) + if commandPartsLen <= 1 { + return nil, errors.New("invalid job command") + } + + action := commandParts[1] + switch action { + case JOB_CREATE_ACTION: + if commandPartsLen <= 3 { + return nil, errors.New("not enough arguments for create job command") + } + + return extractCreateJobCommand(commandParts[2:]) + case JOB_GET_ACTION: + if commandPartsLen == 2 { + return &ports.GetAllJobsCommand{}, nil + } else if commandPartsLen == 3 { + return &ports.GetJobByIdCommand{commandParts[2]}, nil + } + return nil, errors.New("too many arguments for get job command") + default: + return nil, errors.New("unknown job action") + } +} + +func extractCreateJobCommand(args []string) (ports.Command, error) { + jobName := args[0] + imageName := args[1] + + if len(args) == 2 { + return &ports.CreateJobCommand{JobName: jobName, ImageName: imageName}, nil + } + + envVars := make(map[string]string) + for _, keyValuePair := range args[2:] { + parts := strings.SplitN(keyValuePair, ":", 2) + if len(parts) != 2 { + return nil, errors.New("invalid environment variable format") + } + key := parts[0] + value := parts[1] + envVars[key] = value + } + + return &ports.CreateJobCommand{JobName: jobName, ImageName: imageName, EnvironmentVariables: envVars}, nil +} + +func flattenAndRemoveQuotes(slices [][]string) ([]string, error) { + var result []string + for _, elements := range slices { + if len(elements) != 1 { + return nil, errors.New("something went wrong") + } + element := strings.ReplaceAll(elements[0], `"`, "") + result = append(result, element) + } + return result, nil +} diff --git a/cli/consumer-cli/core/cli/command-parser_test.go b/cli/consumer-cli/core/cli/command-parser_test.go new file mode 100644 index 0000000..9d0117f --- /dev/null +++ b/cli/consumer-cli/core/cli/command-parser_test.go @@ -0,0 +1,310 @@ +package core + +import ( + "testing" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +func TestCreateJobCommand(t *testing.T) { + commandParser := CommandParser{} + + tests := []struct { + name string + command string + expectedCommand *ports.CreateJobCommand + }{ + { + name: "Arguments in double quotes", + command: `job create "my test Job" "acc/myImage" "path:some/path/to/file" "user:Tony :Fallony"`, + expectedCommand: &ports.CreateJobCommand{JobName: "my test Job", ImageName: "acc/myImage", EnvironmentVariables: map[string]string{"path": "some/path/to/file", "user": "Tony :Fallony"}}, + }, + { + name: "Some arguments not in double quotes", + command: `job create myJob "acc/myImage" path:some/path/to/file "user:Tony Fallony"`, + expectedCommand: &ports.CreateJobCommand{JobName: "myJob", ImageName: "acc/myImage", EnvironmentVariables: map[string]string{"path": "some/path/to/file", "user": "Tony Fallony"}}, + }, + { + name: "No environment variables", + command: `job create myJob acc/myImage`, + expectedCommand: &ports.CreateJobCommand{JobName: "myJob", ImageName: "acc/myImage"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := commandParser.ParseCommand(tt.command) + if err != nil { + t.Errorf("Error: %s", err) + } + + switch v := result.(type) { + case *ports.CreateJobCommand: + if v.JobName != tt.expectedCommand.JobName { + t.Errorf("Wrong job name. Expected '%s' but got '%s'.", tt.expectedCommand.JobName, v.JobName) + } + if v.ImageName != tt.expectedCommand.ImageName { + t.Errorf("Wrong image name. Expected '%s' but got '%s'.", tt.expectedCommand.ImageName, v.ImageName) + } + for key, expectedValue := range tt.expectedCommand.EnvironmentVariables { + if value, ok := v.EnvironmentVariables[key]; !ok || value != expectedValue { + t.Errorf("Wrong environment variable '%s'. Expected '%s' but got '%s'.", key, expectedValue, value) + } + } + default: + t.Errorf("Create job command is of wrong type: '%T'", result) + } + }) + } +} + +func TestGetAllJobsCommand(t *testing.T) { + commandParser := CommandParser{} + + command := `job get` + + result, err := commandParser.ParseCommand(command) + if err != nil { + t.Errorf("Error: %s", err) + } + + switch result.(type) { + case *ports.GetAllJobsCommand: + if _, ok := result.(*ports.GetAllJobsCommand); !ok { + t.Errorf("Expected GetAllJobsCommand but got '%T'", result) + } + default: + t.Errorf("Get all jobs command is of wrong type: '%T'", result) + } +} + +func TestGetJobByIdCommand(t *testing.T) { + commandParser := CommandParser{} + + command := `job get 123` + expectedCommand := ports.GetJobByIdCommand{ID: "123"} + + result, err := commandParser.ParseCommand(command) + if err != nil { + t.Errorf("Error: %s", err) + } + + switch v := result.(type) { + case *ports.GetJobByIdCommand: + if v.ID != expectedCommand.ID { + t.Errorf("Wrong job ID. Expected '%s' but got '%s'.", expectedCommand.ID, v.ID) + } + default: + t.Errorf("Get job by ID command is of wrong type: '%T'", result) + } +} + +func TestLoginCommand(t *testing.T) { + commandParser := CommandParser{} + + tests := []struct { + name string + command string + expectedCommand ports.LoginCommand + }{ + { + name: "Arguments in double quotes", + command: `login "username" "my secret password"`, + expectedCommand: ports.LoginCommand{UserName: "username", Password: "my secret password"}, + }, + { + name: "Some arguments not in double quotes", + command: `login userName "password"`, + expectedCommand: ports.LoginCommand{UserName: "userName", Password: "password"}, + }, + { + name: "All arguments not in double quotes", + command: `login username password`, + expectedCommand: ports.LoginCommand{UserName: "username", Password: "password"}, + }, + { + name: "Login with empty password", + command: `login "username" ""`, + expectedCommand: ports.LoginCommand{UserName: "username", Password: ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := commandParser.ParseCommand(tt.command) + if err != nil { + t.Errorf("Error: %s", err) + } + + switch v := result.(type) { + case *ports.LoginCommand: + if v.UserName != tt.expectedCommand.UserName { + t.Errorf("Wrong username. Expected '%s' but got '%s'.", tt.expectedCommand.UserName, v.UserName) + } + if v.Password != tt.expectedCommand.Password { + t.Errorf("Wrong password. Expected '%s' but got '%s'.", tt.expectedCommand.Password, v.Password) + } + default: + t.Errorf("Login command is of wrong type: '%T'", result) + } + }) + } +} + +func TestGetConsumerLocationCommand(t *testing.T) { + commandParser := CommandParser{} + + command := `location get` + + result, err := commandParser.ParseCommand(command) + if err != nil { + t.Errorf("Error: %s", err) + } + + switch result.(type) { + case *ports.GetConsumerLocationCommand: + if _, ok := result.(*ports.GetConsumerLocationCommand); !ok { + t.Errorf("Expected GetConsumerLocationCommand but got '%T'", result) + } + default: + t.Errorf("Get consumer location command is of wrong type: '%T'", result) + } +} + +func TestSetConsumerLocationCommand(t *testing.T) { + commandParser := CommandParser{} + + tests := []struct { + name string + command string + expectedCommand ports.SetConsumerLocationCommand + }{ + { + name: "Arguments with quotes", + command: `location set "-122.4194" "37.7749"`, + expectedCommand: ports.SetConsumerLocationCommand{Longitude: 37.7749, Latitude: -122.4194}, + }, + { + name: "Some arguments with quotes", + command: `location set "123" 0 `, + expectedCommand: ports.SetConsumerLocationCommand{Longitude: 0, Latitude: 123}, + }, + { + name: "No arguments with quotes", + command: `location set -122.4194 -37.7749`, + expectedCommand: ports.SetConsumerLocationCommand{Longitude: -37.7749, Latitude: -122.4194}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := commandParser.ParseCommand(tt.command) + if err != nil { + t.Errorf("Error: %s", err) + } + + switch v := result.(type) { + case *ports.SetConsumerLocationCommand: + if v.Longitude != tt.expectedCommand.Longitude { + t.Errorf("Wrong longitude. Expected '%f' but got '%f'.", tt.expectedCommand.Longitude, v.Longitude) + } + if v.Latitude != tt.expectedCommand.Latitude { + t.Errorf("Wrong latitude. Expected '%f' but got '%f'.", tt.expectedCommand.Latitude, v.Latitude) + } + default: + t.Errorf("Set consumer location command is of wrong type: '%T'", result) + } + }) + } +} + +func TestHelpCommand(t *testing.T) { + commandParser := CommandParser{} + + command := `help` + + result, err := commandParser.ParseCommand(command) + if err != nil { + t.Errorf("Error: %s", err) + } + + switch result.(type) { + case *ports.HelpCommand: + if _, ok := result.(*ports.HelpCommand); !ok { + t.Errorf("Expected HelpCommand but got '%T'", result) + } + default: + t.Errorf("Help command is of wrong type: '%T'", result) + } +} + +func TestInvalidCommands(t *testing.T) { + commandParser := CommandParser{} + + tests := []struct { + name string + command string + }{ + { + name: "Empty command", + command: ``, + }, + { + name: "Unknown command", + command: `unknown command`, + }, + { + name: "Job command with invalid action", + command: `job unknown`, + }, + { + name: "Create job command with missing args", + command: `job create`, + }, + { + name: "Create job command with missing image name", + command: `job create myJob`, + }, + { + name: "Create job command with invalid envs", + command: `job create myJob myImage "key1"`, + }, + { + name: "Invalid job get command with too many arguments", + command: `job get 123 extra`, + }, + { + name: "Invalid location action", + command: `location unknown`, + }, + { + name: "Invalid location set command with missing arguments", + command: `location set 37.7749`, + }, + { + name: "Invalid location set command with non numeric longitude", + command: `location set longi 37.7749`, + }, + { + name: "Invalid location set command with non numeric latitude", + command: `location set 37.7749 lati`, + }, + { + name: "Invalid login command with missing arguments", + command: `login`, + }, + { + name: "Invalid login command with missing password", + command: `login username`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := commandParser.ParseCommand(tt.command) + if err == nil { + t.Errorf("Expected error for command '%s' but got none", tt.command) + } + }) + } +} diff --git a/cli/consumer-cli/core/location-store/location-store.go b/cli/consumer-cli/core/location-store/location-store.go new file mode 100644 index 0000000..61eaf48 --- /dev/null +++ b/cli/consumer-cli/core/location-store/location-store.go @@ -0,0 +1,83 @@ +package location_store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +type locationStore struct { +} + +type LocationStore interface { + GetLocation() (*ports.Geolocation, error) + SetLocation(latitude float64, longitude float64) error +} + +func NewLocationStore() *locationStore { + return &locationStore{} +} + +const filename = ".userlocation" + +func (store *locationStore) GetLocation() (*ports.Geolocation, error) { + geo, err := readFile() + if err != nil { + return nil, err + } + return geo, nil +} + +func (store *locationStore) SetLocation(latitude float64, longitude float64) error { + geo := ports.Geolocation{ + Latitude: latitude, + Longitude: longitude, + } + err := writeToFile(geo) + return err +} + +func readFile() (*ports.Geolocation, error) { + exePath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("error finding executable path: %w", err) + } + configDir := filepath.Dir(exePath) + configFilePath := filepath.Join(configDir, filename) + + file, err := os.Open(configFilePath) + if err != nil { + return nil, fmt.Errorf("location was not set") + } + defer file.Close() + location := &ports.Geolocation{} + decoder := json.NewDecoder(file) + if err := decoder.Decode(location); err != nil { + return nil, fmt.Errorf("error decoding file: %w", err) + } + return location, nil +} + +func writeToFile(location ports.Geolocation) error { + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("error finding executable path: %w", err) + } + configDir := filepath.Dir(exePath) + configFilePath := filepath.Join(configDir, filename) + + file, err := os.Create(configFilePath) + if err != nil { + return fmt.Errorf("error opening file: %w", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + if err := encoder.Encode(location); err != nil { + return fmt.Errorf("error encoding file: %w", err) + } + return nil +} diff --git a/cli/consumer-cli/core/location-store/location-store_test.go b/cli/consumer-cli/core/location-store/location-store_test.go new file mode 100644 index 0000000..13013e9 --- /dev/null +++ b/cli/consumer-cli/core/location-store/location-store_test.go @@ -0,0 +1,61 @@ +package location_store + +import ( + "encoding/json" + _ "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/ports" +) + +func TestSetLocation(t *testing.T) { + store := NewLocationStore() + + err := store.SetLocation(40.7128, -74.0060) + if err != nil { + t.Errorf("Error setting location: %v", err) + } + + exePath, _ := os.Executable() + configFilePath := filepath.Join(filepath.Dir(exePath), filename) + t.Log(configFilePath) + file, err := os.Open(configFilePath) + if err != nil { + t.Fatalf("Error opening config file: %v", err) + } + defer file.Close() + + var savedLocation ports.Geolocation + decoder := json.NewDecoder(file) + if err := decoder.Decode(&savedLocation); err != nil { + t.Fatalf("Error decoding saved location: %v", err) + } + + expectedLocation := ports.Geolocation{Latitude: 40.7128, Longitude: -74.0060} + if savedLocation != expectedLocation { + t.Errorf("Expected %v, got %v", expectedLocation, savedLocation) + } +} + +func TestGetLocation(t *testing.T) { + mockLocation := ports.Geolocation{Latitude: 51.5074, Longitude: -0.1278} + + store := NewLocationStore() + + err := store.SetLocation(mockLocation.Latitude, mockLocation.Longitude) + if err != nil { + t.Errorf("Error setting location: %v", err) + } + + location, err := store.GetLocation() + if err != nil { + t.Errorf("Error getting location: %v", err) + } + + if reflect.DeepEqual(location, mockLocation) { + t.Errorf("Expected %v, got %v", mockLocation, *location) + } +} diff --git a/cli/consumer-cli/go.mod b/cli/consumer-cli/go.mod new file mode 100644 index 0000000..d63580c --- /dev/null +++ b/cli/consumer-cli/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli + +go 1.23.1 + +require github.com/google/uuid v1.6.0 diff --git a/cli/consumer-cli/go.sum b/cli/consumer-cli/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/cli/consumer-cli/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/cli/consumer-cli/main.go b/cli/consumer-cli/main.go new file mode 100644 index 0000000..0460c2e --- /dev/null +++ b/cli/consumer-cli/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "bufio" + "fmt" + "os" + + rest_client_mock "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/adapter/mocks/rest_client_mock" + cli "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/core/cli" + location_store "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/consumer-cli/core/location-store" +) + +func main() { + restClient := rest_client_mock.NewMockRestClient() + locationStore := location_store.NewLocationStore() + commandHandler := cli.NewCommandHandler(restClient, locationStore) + cli := cli.NewCli(commandHandler) + + for true { + fmt.Print("Input: ") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + cli.Scan(input) + } +} diff --git a/cli/consumer-cli/ports/api.go b/cli/consumer-cli/ports/api.go new file mode 100644 index 0000000..e91d7e7 --- /dev/null +++ b/cli/consumer-cli/ports/api.go @@ -0,0 +1,56 @@ +package ports + +type Api interface { + HandleCreateJob(command CreateJobCommand) (*BaseJob, error) + HandleGetJobById(command GetJobByIdCommand) (*GetJobDto, error) + HandleGetAllJobs(command GetAllJobsCommand) ([]GetJobDto, error) + HandleLogin(command LoginCommand) (*LoginResponseDto, error) + HandleHelp(command HelpCommand) string + HandleSetConsumerLocation(command SetConsumerLocationCommand) error + HandleGetConsumerLocation(command GetConsumerLocationCommand) (*Geolocation, error) +} + +type Command interface { + implementCommand() +} + +type CreateJobCommand struct { + JobName string + ImageName string + EnvironmentVariables map[string]string +} + +type GetAllJobsCommand struct{} + +type GetJobByIdCommand struct { + ID string +} + +type LoginCommand struct { + UserName string + Password string +} + +type GetConsumerLocationCommand struct { +} + +type SetConsumerLocationCommand struct { + Longitude float64 + Latitude float64 +} + +type HelpCommand struct{} + +func (c *CreateJobCommand) implementCommand() {} + +func (c *GetAllJobsCommand) implementCommand() {} + +func (c *GetJobByIdCommand) implementCommand() {} + +func (c *LoginCommand) implementCommand() {} + +func (c *GetConsumerLocationCommand) implementCommand() {} + +func (c *SetConsumerLocationCommand) implementCommand() {} + +func (c *HelpCommand) implementCommand() {} diff --git a/cli/consumer-cli/ports/jwt-store.go b/cli/consumer-cli/ports/jwt-store.go new file mode 100644 index 0000000..98acd41 --- /dev/null +++ b/cli/consumer-cli/ports/jwt-store.go @@ -0,0 +1,7 @@ +package ports + +type JWTStore interface { + GetJWT() string + SetJWT(jwt string) + DeleteJWT() +} diff --git a/cli/consumer-cli/ports/model.go b/cli/consumer-cli/ports/model.go new file mode 100644 index 0000000..2f61b0a --- /dev/null +++ b/cli/consumer-cli/ports/model.go @@ -0,0 +1,63 @@ +package ports + +type GetJobDto struct { + Id string `json:"id"` + Name string `json:"name"` + ConsumerId string `json:"consumerId"` + ImageName string `json:"imageName"` + EnvironmentVariables map[string]string `json:"environmentVariables"` + Status JobStatus `json:"status"` + StandardOutput string `json:"standardOutput"` + + CreatedAt int64 `json:"createdAt"` + StartedAt int64 `json:"startedAt"` + FinishedAt int64 `json:"finishedAt"` + + ConsumerLongitude float64 `json:"consumerLongitude"` + ConsumerLatitude float64 `json:"consumerLatitude"` + Co2EquivalentEmissionConsumer float64 `json:"co2EquivalentEmissionConsumer"` + + EstimatedCo2Equivalent float64 `json:"estimatedCo2Equivalent"` +} + +type JobStatus string + +const ( + CREATED JobStatus = "CREATED" // job created, but not assigned to worker yet + PENDING JobStatus = "PENDING" // assigned to worker, but not running yet + RUNNING JobStatus = "RUNNING" + FINISHED JobStatus = "FINISHED" + FAILED JobStatus = "FAILED" +) + +type CreateJobDto struct { + RequestId string `json:"requestId"` + Job BaseJob `json:"job"` +} + +type LoginDto struct { + RequestId string `json:"requestId"` + Credentials Credentials `json:"credentials"` +} + +type LoginResponseDto struct { + Token string `json:"token"` +} + +type BaseJob struct { + Name string `json:"name"` + ImageName string `json:"imageName"` + EnvironmentVariables map[string]string `json:"environmentVariables"` + ConsumerLongitude float64 `json:"consumerLongitude"` + ConsumerLatitude float64 `json:"consumerLatitude"` +} + +type Credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type Geolocation struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} diff --git a/cli/consumer-cli/ports/rest-client.go b/cli/consumer-cli/ports/rest-client.go new file mode 100644 index 0000000..49d05ab --- /dev/null +++ b/cli/consumer-cli/ports/rest-client.go @@ -0,0 +1,14 @@ +package ports + +import ( + "errors" +) + +var ErrEntityNotFound = errors.New("entity not found") + +type RestClient interface { + GetJob(id string) (*GetJobDto, error) + GetJobs() ([]GetJobDto, error) + CreateJobDto(dto CreateJobDto) error + Login(dto LoginDto) (*LoginResponseDto, error) +} diff --git a/cli/gcls-modulith/cmd/cmd.go b/cli/gcls-modulith/cmd/cmd.go new file mode 100644 index 0000000..0905fe3 --- /dev/null +++ b/cli/gcls-modulith/cmd/cmd.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + "os" +) + +type Command struct { + Use string + Description string + Run func([]string) +} + +var commands = make(map[string]Command) + +func Execute() { + if len(os.Args) < 2 { + fmt.Println("Not enough arguments!") + os.Exit(1) + } + + command, ok := commands[os.Args[1]] + if !ok { + fmt.Println("Unknown command!") + os.Exit(1) + } + + command.Run(os.Args[2:]) +} + +func RegisterCommand(command Command) { + commands[command.Use] = command +} diff --git a/cli/gcls-modulith/cmd/docker_image.go b/cli/gcls-modulith/cmd/docker_image.go new file mode 100644 index 0000000..d6d3caf --- /dev/null +++ b/cli/gcls-modulith/cmd/docker_image.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "fmt" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/gcls-modulith/core" +) + +func init() { + RegisterCommand(Command{ + Use: "gcls-modulith", + Description: "A container image is passed, turned into a job and then executed.", + Run: func(args []string) { + if len(args) != 1 { + fmt.Println("Invalid number of arguments!") + return + } + + core.ExecuteComputeJob(args[0]) + }, + }) +} diff --git a/cli/gcls-modulith/core/modulith.go b/cli/gcls-modulith/core/modulith.go new file mode 100644 index 0000000..6b6b864 --- /dev/null +++ b/cli/gcls-modulith/core/modulith.go @@ -0,0 +1,83 @@ +package core + +import ( + "context" + "fmt" + "time" + + worker_client "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/gcls-modulith/services/gcls-worker-daemon/adapters/client-cli" + worker_executor "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/gcls-modulith/services/gcls-worker-daemon/adapters/executor-cli" + worker_core "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/core" + worker_ports "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/ports" + + worker_repo "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/adapters/repo-in-memory" + worker_registry_core "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/core" + + job_repo "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/adapters/repo-in-memory" + job_core "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/core" + job_ports "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/ports" + // job_scheduler_core "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job-scheduler/core" +) + +func ExecuteComputeJob(dockerImage string) { + workerRegistryService := worker_registry_core.NewWorkerRegistryService(worker_repo.NewRepo()) + jobService := job_core.NewJobService(job_repo.NewRepo()) + // jobSchedulerService := job_scheduler_core.NewJobSchedulerService(nil, nil) + + workerDaemon := worker_core.NewWorkerDaemon( + worker_client.NewClient(workerRegistryService, jobService), + worker_executor.NewExecutor(), + &worker_ports.Config{ + Longitude: 1.0, + Latitude: 1.0, + GatewayUrl: "don't care", + }, + ) + + ctx, cancel := context.WithCancel(context.Background()) + + workerDone := make(chan struct{}) + // schedulerDone := make(chan struct{}) + + go workerDaemon.Start(ctx, workerDone) + // go jobSchedulerService.Schedule(ctx, schedulerDone) + + jobService.CreateJob( + context.Background(), + job_ports.CreateJobParams{ + Name: "A cool job", + ImageName: "A cool image", + ConsumerLongitude: 1.0, + ConsumerLatitude: 1.0, + EnvironmentVariables: make(map[string]string), + }, + "Gandalf", + ) + + for { + jobs, err := jobService.GetJobsForConsumer(context.Background(), "Gandalf") + if err != nil { + fmt.Printf("Encountered error while polling jobs for Gandalf: %v\n", err) + break + } + + if len(jobs) != 1 { + fmt.Println("There should exactly be one job from Gandalf") + break + } + + job := jobs[0] + if job.Status == job_ports.FINISHED || job.Status == job_ports.FAILED { + break + } + + fmt.Println("Waiting for Job to be done.") + time.Sleep(3 * time.Second) + + } + + cancel() + + <-workerDone + // <-schedulerDone +} diff --git a/cli/gcls-modulith/go.mod b/cli/gcls-modulith/go.mod new file mode 100644 index 0000000..94e5b0b --- /dev/null +++ b/cli/gcls-modulith/go.mod @@ -0,0 +1,19 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/gcls-modulith + +go 1.23.2 + +replace gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon => ../../services/gcls-worker-daemon + +replace gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job => ../../services/job + +replace gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry => ../../services/worker-registry + +replace gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job-scheduler => ../../services/job-scheduler + +require ( + gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon v0.0.0-00010101000000-000000000000 + gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job v0.0.0-00010101000000-000000000000 + gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry v0.0.0-00010101000000-000000000000 +) + +require github.com/google/uuid v1.6.0 // indirect diff --git a/cli/gcls-modulith/go.sum b/cli/gcls-modulith/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/cli/gcls-modulith/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/cli/gcls-modulith/main.go b/cli/gcls-modulith/main.go new file mode 100644 index 0000000..587e0b9 --- /dev/null +++ b/cli/gcls-modulith/main.go @@ -0,0 +1,7 @@ +package main + +import "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/cli/gcls-modulith/cmd" + +func main() { + cmd.Execute() +} diff --git a/cli/gcls-modulith/services/gcls-worker-daemon/adapters/client-cli/client.go b/cli/gcls-modulith/services/gcls-worker-daemon/adapters/client-cli/client.go new file mode 100644 index 0000000..ae889f9 --- /dev/null +++ b/cli/gcls-modulith/services/gcls-worker-daemon/adapters/client-cli/client.go @@ -0,0 +1,75 @@ +package client_cli + +import ( + "context" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/ports" + job_ports "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/ports" + worker_registry_ports "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/ports" +) + +type Client struct { + WorkerRegistryService worker_registry_ports.Api + JobService job_ports.Api +} + +var _ ports.Client = (*Client)(nil) + +func NewClient(workerRegistryService worker_registry_ports.Api, jobService job_ports.Api) *Client { + return &Client{WorkerRegistryService: workerRegistryService, JobService: jobService} +} + +func (c *Client) Register(worker ports.Worker) error { + return c.WorkerRegistryService.RegisterWorker( + worker_registry_ports.Worker{ + Id: worker.Id, + Status: worker_registry_ports.WorkerStatus(worker.Status), + Location: worker_registry_ports.Location{ + Latitude: worker.Location.Latitude, + Longitude: worker.Location.Longitude, + }, + }, + context.Background()) +} + +func (c *Client) Unregister(id string) error { + return c.WorkerRegistryService.UnregisterWorker(id, context.Background()) +} + +func (c *Client) GetJobs(id string) ([]ports.Job, error) { + jobs, err := c.JobService.GetJobsForWorker(context.Background(), id) + if err != nil { + return nil, err + } + + workerJobs := make([]ports.Job, len(jobs)) + for i, job := range jobs { + workerJobs[i] = ports.Job{ + Id: job.Id, + ImageName: job.ImageName, + EnvironmentVariables: job.EnvironmentVariables, + Status: ports.JobStatus(job.Status), + StartedAt: job.StartedAt, + FinishedAt: job.FinishedAt, + } + } + + return workerJobs, nil +} + +func (c *Client) UpdateStatus(id string, status ports.WorkerStatus) error { + return c.WorkerRegistryService.UpdateWorker(id, worker_registry_ports.WorkerStatus(status), context.Background()) +} + +func (c *Client) UpdateJob(id string, stdout string, startedAt int64, finishedAt int64, status ports.JobStatus) error { + return c.JobService.UpdateJobForWorker( + context.Background(), + id, + job_ports.UpdateJobForWorkerParams{ + StandardOutput: stdout, + StartedAt: startedAt, + FinishedAt: finishedAt, + Status: job_ports.JobStatus(status), + }, + ) +} diff --git a/cli/gcls-modulith/services/gcls-worker-daemon/adapters/executor-cli/executor.go b/cli/gcls-modulith/services/gcls-worker-daemon/adapters/executor-cli/executor.go new file mode 100644 index 0000000..453ac86 --- /dev/null +++ b/cli/gcls-modulith/services/gcls-worker-daemon/adapters/executor-cli/executor.go @@ -0,0 +1,20 @@ +package executor_cli + +import "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/ports" + +type Executor struct{} + +var _ ports.Executor = (*Executor)(nil) + +func NewExecutor() *Executor { + return &Executor{} +} + +func (c *Executor) Execute(job ports.Job) (ports.ExecutionResult, error) { + return ports.ExecutionResult{ + Status: ports.SUCCESS, + StartedAt: 0, + FinishedAt: 0, + Stdout: "Hello, World!", + }, nil +} diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..998ab9e --- /dev/null +++ b/doc/README.md @@ -0,0 +1,190 @@ +# Green Compute Load Shifting (GCLS) + +## Use Cases + +![use cases](use-cases.png) + +## Building Blocks + +![building blocks](building-blocks.png) + +| Building Block | Type | Purpose | Owner | +| ------ | --- | -------------- | --- | +| GCLS Worker Daemon | CLI | Provide computing capacity to platform; Execution of jobs | Team 1 | +| Worker Gateway | Microservice | Provides unified API for the GCLS Worker Daemon | Team 1 | +| Worker Registry | Microservice | Holds Status of Worker Daemons | Team 1 | +| GCLS Consumer CLI | CLI | Place compute jobs; Receive Job Status/Results | Team 2 | +| Consumer Gateway | Microservice | Provides unified API for the GCLS Consumer CLI | Team 2 | +| Job | Microservice | Manages the status of Jobs | Team 3 | +| User Management | Microservice | Manages Users | Team 3 | +| Job Scheduler | Microservice | Schedules Jobs | Team 4 | +| Carbon Intensity Provider | Microservice | Provides Carbon Intensity | Team 4 | + +## Approved Packages + +- [google/uuid](https://pkg.go.dev/github.com/google/uuid) +- [opentelemetry](https://pkg.go.dev/go.opentelemetry.io/otel) + +## Runtime Scenarios + +### Run Compute Job + +```mermaid +sequenceDiagram + participant worker as GCLS Worker Daemon + participant gateway as Worker Gateway + participant registry as Worker Registry + participant job as Job + + loop every 5s + worker->>gateway: UpdateStatus(worker) + activate gateway + gateway->>registry: UpdateStatus(worker) + activate registry + registry-->>gateway: 200 OK + deactivate registry + gateway-->>worker: 200 OK + deactivate gateway + end + + loop + worker->>gateway: GetJob(worker) + activate gateway + gateway->>job: GetJob(worker) + activate job + job-->>gateway: Job + deactivate job + gateway-->>worker: Job + deactivate gateway + + worker->>worker: SetStatus("EXHAUSTED") + worker->>worker: Execute(job) + + worker->>gateway: Update(job) + activate gateway + gateway->>job: Update(job) + activate job + job-->>gateway: 200 OK + deactivate job + gateway-->>worker: 200 OK + deactivate gateway + + worker->>worker: SetStatus("REQUIRES_WORK") + end +``` + +### Create Compute Job + +```mermaid create_job +sequenceDiagram + actor ComputeConsumer as Compute Consumer + + ComputeConsumer ->> GCLS_CLI : Job anlegen mit Job-Definition + + note right of GCLS_CLI: Annahme: Der User ist bereits registriert + + GCLS_CLI ->> GCLS_CLI : JWT aus Speicher lesen + + alt Gültiger JWT vorhanden + GCLS_CLI ->> ConsumerGateway : POST /jobs (+ JWT) + ConsumerGateway ->> ConsumerGateway : Validiere JWT + alt JWT gültig + ConsumerGateway ->> Job : POST /jobs (+ JWT) + + alt Job und JWT gültig + Job -->> ConsumerGateway : Job gültig und angelegt + ConsumerGateway -->> GCLS_CLI : Job gültig und angelegt + GCLS_CLI -->> ComputeConsumer : Job erfolgreich angelegt + else Job ungültig + Job -->> ConsumerGateway : Job ungültig + ConsumerGateway -->> GCLS_CLI : Job ungültig + GCLS_CLI -->> ComputeConsumer : Fehlernachricht "ungültiger Job" + else JWT ungültig + Job -->> ConsumerGateway : JWT ungültig + ConsumerGateway -->> GCLS_CLI : JWT ungültig + GCLS_CLI -->> ComputeConsumer : Fehlernachricht "nicht authentifiziert" + end + else JWT ungültig + ConsumerGateway -->> GCLS_CLI : JWT ungültig + GCLS_CLI -->> ComputeConsumer : Fehlernachricht "nicht authentifiziert" + end + else JWT nicht vorhanden oder abgelaufen + GCLS_CLI -->> ComputeConsumer : Fehlernachricht "nicht authentifiziert" + end +``` + +### Login + +```mermaid login +sequenceDiagram + actor ComputeConsumer as Compute Consumer + ComputeConsumer ->> GCLS_CLI: Anmelden mit Nutzername + Passwort + GCLS_CLI ->> ConsumerGateway: POST /login + + ConsumerGateway ->> UserManagement: POST /login + + alt Zugangsdaten gültig und Anfrage authentifiziert + UserManagement -->> ConsumerGateway: JWT + ConsumerGateway -->> GCLS_CLI: JWT + GCLS_CLI ->> GCLS_CLI: JWT sicher speichern + GCLS_CLI -->> ComputeConsumer: Erfolgreich angemeldet + else Zugangsdaten ungültig + UserManagement -->> ConsumerGateway: ungültige Zugangsdaten + ConsumerGateway -->> GCLS_CLI: ungültige Zugangsdaten + GCLS_CLI -->> ComputeConsumer: Fehlernachricht "ungültige Zugangsdaten" + else Anfrage (über basic auth) nicht authentifiziert + UserManagement -->> ConsumerGateway: Anfrage über basic auth nicht authentifiziert + ConsumerGateway -->> GCLS_CLI: interner Fehler + GCLS_CLI -->> ComputeConsumer: Fehlernachricht "interner Fehler" + end +``` + +### Get Job Statistics + +```mermaid +sequenceDiagram + actor Consumer + note right of Consumer: Annahme: Der User ist bereits eingeloggt + note right of GCLS_CLI: Annahme: Dauerhafte Netzwerk- und Endpoint-Erreichbarkeit + Consumer ->> GCLS_CLI: Request Job statistics + alt Gültiger JWT vorhanden + GCLS_CLI ->> Consumer-Gateway : POST /jobs (+ JWT) + Consumer-Gateway ->> Consumer-Gateway : Validiere JWT + alt JWT gültig + Consumer-Gateway ->> Job : POST /jobs (+ JWT) + + alt Job und JWT gültig + Job -->> Job: Retrieve statistics from database + alt Database success + Job -->> Consumer-Gateway : Return job statistics + Consumer-Gateway -->> GCLS_CLI : Return job statistics + GCLS_CLI -->> Consumer : Return job statistics + else Database error + Job -->> Consumer-Gateway : Retrieving failed + Consumer-Gateway -->> GCLS_CLI : Retrieving failed + GCLS_CLI -->> Consumer : Fehlernachricht "Datenbankfehler" + end + else JWT ungültig + Job -->> Consumer-Gateway : JWT ungültig + Consumer-Gateway -->> GCLS_CLI : JWT ungültig + GCLS_CLI -->> Consumer : Fehlernachricht "nicht authentifiziert" + end + else JWT ungültig vom Consumer-Gateway + Consumer-Gateway -->> GCLS_CLI : JWT ungültig + GCLS_CLI -->> Consumer : Fehlernachricht "nicht authentifiziert" + end + end +``` + +### Schedule Job + +![jobs-worker-cip](jobs-worker-cip.jpeg) + +## Cross Cutting Concerns + +| Aspect | Owner | +| --- | --- | +| Observability | Team 1 | +| DevOps | Team 2 | +| Security | Team 3 | +| Communication Resilience | Team 4 | diff --git a/doc/building-blocks.png b/doc/building-blocks.png new file mode 100644 index 0000000..4fae6ea Binary files /dev/null and b/doc/building-blocks.png differ diff --git a/doc/green-compute-load-shifting.drawio b/doc/green-compute-load-shifting.drawio new file mode 100644 index 0000000..8f57032 --- /dev/null +++ b/doc/green-compute-load-shifting.drawio @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/job.go b/doc/job.go new file mode 100644 index 0000000..c347130 --- /dev/null +++ b/doc/job.go @@ -0,0 +1,39 @@ +package job + +type Job struct { + id string + name string // descriptive name for job, helps consumers to identify job + consumerId string // defined at job creation + workerId string // defined when job assigned to worker + imageName string // Name from DockerHub, e.g. "postgres" or "chainguard/jdk" + environmentVariables map[string]string // e.g. {"POSTGRES_DB": "test_db", ...} + status JobStatus + standardOutput string + + createdAt int64 // for priority -> first come first serve + // Unix timestamps in milliseconds, needed to calculate estimated CO2 equivalent: + startedAt int64 + finishedAt int64 + + // needed for calculating the diff between executing job at worker and consumer location + ConsumerLongitude float64 + ConsumerLatitude float64 + Co2EquivalentEmissionConsumer float64 // emission of the consumer location (the current emission when job is scheduled) + + WorkerLongitude float64 + WorkerLatitude float64 + Co2EquivalentEmissionWorker float64 // emission of the worker location (the current emission when job is scheduled) + + // final co2 equivalent of the finished job + estimatedCo2Equivalent float64 +} + +type JobStatus string + +const ( + CREATED JobStatus = "CREATED" // job created, but not assigned to worker yet + PENDING JobStatus = "PENDING" // assigned to worker, but not running yet + RUNNING JobStatus = "RUNNING" + FINISHED JobStatus = "FINISHED" + FAILED JobStatus = "FAILED" +) diff --git a/doc/jobs-worker-cip.jpeg b/doc/jobs-worker-cip.jpeg new file mode 100644 index 0000000..d85fc2b Binary files /dev/null and b/doc/jobs-worker-cip.jpeg differ diff --git a/doc/overview.png b/doc/overview.png new file mode 100644 index 0000000..6c0b796 Binary files /dev/null and b/doc/overview.png differ diff --git a/doc/use-cases.png b/doc/use-cases.png new file mode 100644 index 0000000..efb3931 Binary files /dev/null and b/doc/use-cases.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..73fb953 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/3000766/cmg-ws202425 + +go 1.23.1 diff --git a/infrastructure/.terraform.lock.hcl b/infrastructure/.terraform.lock.hcl new file mode 100644 index 0000000..8cd4153 --- /dev/null +++ b/infrastructure/.terraform.lock.hcl @@ -0,0 +1,21 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.2.0" + hashes = [ + "h1:7JIgzQKRW0AT6UliiYSjYUKxDr03baZpQmt5XVkrujs=", + "zh:08a7dc0b53d2b63baab928e66086bf3e09107516af078ce011d2667456e64834", + "zh:1cf9a1373e516844b43fdcea36e73f5a68f19ad07afcf6093788eb235c710163", + "zh:2d4a7cb26c3f0d036d51db219a09013d3d779e44d584e0fc631df0f2cd5e5550", + "zh:47e1fc68e455f99f1875deaed9aa5434a852e2a70a3cb5a5e9b5a2d8c25d7b74", + "zh:78531a8624ddcd45277e1b465e773ac92001ea0e200e9dc1147ebeb24d56359e", + "zh:a76751723c034d44764df22925178f78d8b4852e3e6ac6c5d86f51666c9e666c", + "zh:a83a59a7e667cfffb0d501a501e9b3d2d4fcc83deb07a318c9690d537cbdc4b6", + "zh:b16473b7e59e01690d8234a0044c304505688f5518b205e9ed06fc63ddc82977", + "zh:b957648ad0383e17149bf3a02def81ebc6bd55ca0cffb6ec1c368a1b4f33c4fd", + "zh:e2f3f4a27b41a20bdbb7a80fbcde1a4c36bbd1c83edb9256bc1724754f8d370f", + "zh:ecfce738f85a81603aa51162d5237d6faaa2ffc0f0e52694f8b420ad761a8957", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 0000000..d1be283 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,6 @@ +# Infrastructure + +This folder is reserved for defining the cloud infrastructure using terraform. + +> **NOTE** +> Run `gcloud auth application-default login` before running terraform to gain access to the project. diff --git a/infrastructure/infrastructure.tf b/infrastructure/infrastructure.tf new file mode 100644 index 0000000..13ff3e7 --- /dev/null +++ b/infrastructure/infrastructure.tf @@ -0,0 +1,3 @@ +provider "google" { + project = "cloud-infra-demo" +} diff --git a/pkg/README.md b/pkg/README.md new file mode 100644 index 0000000..6aa5939 --- /dev/null +++ b/pkg/README.md @@ -0,0 +1,3 @@ +# Pkg-Folder + +This folder is reserved for Go packages that can be used in multiple services. It is intended for the implementation of cross cutting concerns. Do not share any domain specific code via a package. Use API calls instead. diff --git a/pkg/learn-go/1912277/go.mod b/pkg/learn-go/1912277/go.mod new file mode 100644 index 0000000..f54838f --- /dev/null +++ b/pkg/learn-go/1912277/go.mod @@ -0,0 +1,3 @@ +module even_odd + +go 1.23.2 diff --git a/pkg/learn-go/1912277/odd_even.go b/pkg/learn-go/1912277/odd_even.go new file mode 100644 index 0000000..8faa9cf --- /dev/null +++ b/pkg/learn-go/1912277/odd_even.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func SortArrayByParity(Input string) (map[string][]int, error) { + + //array of type int for unmarshal + + var nums []int + + //unmarshal the JSON String from above + + err := json.Unmarshal([]byte(Input), &nums) + + if err != nil { + + fmt.Println("error parsing JSON:", err) + return nil, err + } + //map to store odd and even numbers seperate + numMap := map[string][]int{ + + "odd": {}, + "even": {}, + } + //sorting after odd and even + for _, num := range nums { + + if num%2 == 0 { + numMap["even"] = append(numMap["even"], num) + } else { + + numMap["odd"] = append(numMap["odd"], num) + } + } + //sorting the individual arrays + sort.Ints(numMap["even"]) + sort.Ints(numMap["odd"]) + + return numMap, nil +} + +func main() { + + //example input + jsonInput := `[2, 5, 9, 12, 36, 43]` + + sortedMap, err := SortArrayByParity(jsonInput) + + if err != nil { + fmt.Println("Error:", err) + } else { + fmt.Printf("Result: %v\n", sortedMap) + } + +} diff --git a/pkg/learn-go/1912277/odd_even_test.go b/pkg/learn-go/1912277/odd_even_test.go new file mode 100644 index 0000000..242b202 --- /dev/null +++ b/pkg/learn-go/1912277/odd_even_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "errors" + "reflect" + "testing" +) + +// case 1: Test the sort method with Input +func TestSortArrayByParity(t *testing.T) { + + jsonInput := `[4, 50, 22, 5, 17, 32]` + + expected := map[string][]int{ + + "even": {4, 22, 32, 50}, + "odd": {5, 17}, + } + result, err := SortArrayByParity(jsonInput) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !reflect.DeepEqual(result, expected) { + + t.Errorf("Test failed. Expected %v, got %v", expected, result) + } + + //case 2: empty array + + jsonInput = `[]` + + expected = map[string][]int{ + + "even": {}, + "odd": {}, + } + + result, err = SortArrayByParity(jsonInput) + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !reflect.DeepEqual(result, expected) { + + t.Errorf("Test failed. Expected %v, got %v", expected, result) + } + + //case 3: All odd numbers + + jsonInput = `[1, 3, 5, 7, 9]` + + expected = map[string][]int{ + + "even": {}, + "odd": {1, 3, 5, 7, 9}, + } + result, err = SortArrayByParity(jsonInput) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !reflect.DeepEqual(result, expected) { + + t.Errorf("Test failed. Expected %v, got %v", expected, result) + } + + //case 4: All even numbers + + jsonInput = `[2, 4, 6, 8, 10]` + + expected = map[string][]int{ + + "even": {2, 4, 6, 8, 10}, + "odd": {}, + } + result, err = SortArrayByParity(jsonInput) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !reflect.DeepEqual(result, expected) { + + t.Errorf("Test failed. Expected %v, got %v", expected, result) + } + + //case 5: invalid Format + + jsonInput = `[1, 2, abc]` + + _, err = SortArrayByParity(jsonInput) + if err == nil { + t.Errorf("expected error but got none") + } else { + var syntaxError *json.SyntaxError + if !errors.As(err, &syntaxError) { + t.Errorf("expected json.SyntaxError but got %T", err) + } + } +} diff --git a/pkg/learn-go/2024219/go.mod b/pkg/learn-go/2024219/go.mod new file mode 100644 index 0000000..afdf03b --- /dev/null +++ b/pkg/learn-go/2024219/go.mod @@ -0,0 +1,3 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/learn-go/2024219 + +go 1.23.1 \ No newline at end of file diff --git a/pkg/learn-go/2024219/odd_even_separator/numbers.json b/pkg/learn-go/2024219/odd_even_separator/numbers.json new file mode 100644 index 0000000..1ea8f53 --- /dev/null +++ b/pkg/learn-go/2024219/odd_even_separator/numbers.json @@ -0,0 +1 @@ +[12, 45, 23, 67, 89, 34, 78, 90, 11, 56, 32, 77, 54, 21, 88, 99, 17, 38, 65, 48, 22, 13, 72, 83, 61, 27] \ No newline at end of file diff --git a/pkg/learn-go/2024219/odd_even_separator/odd_even_separator.go b/pkg/learn-go/2024219/odd_even_separator/odd_even_separator.go new file mode 100644 index 0000000..809b7b1 --- /dev/null +++ b/pkg/learn-go/2024219/odd_even_separator/odd_even_separator.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "os" + "sort" +) + +func main() { + + data, err := os.ReadFile("numbers.json") + if err != nil { + log.Fatalf("Failed to open file: %v", err) + } + + result, err := SeparateOddEven(string(data)) + if err != nil { + log.Fatalf("Failed to separate odd and even numbers: %v", err) + } + + PrintOddEvenMap(os.Stdout, result) +} + +// SeparateOddEven sorts and separates odd and even numbers from a JSON array input +// assuming that file handling is done elsewehere and that we pass the input as a string. +func SeparateOddEven(jsonInput string) (map[string][]int, error) { + + var numbers []int + err := json.Unmarshal([]byte(jsonInput), &numbers) + if err != nil { + return nil, err + } + + result := make(map[string][]int) + result["odd"] = []int{} + result["even"] = []int{} + + for _, num := range numbers { + if num%2 == 0 { + result["even"] = append(result["even"], num) + } else { + result["odd"] = append(result["odd"], num) + } + } + sort.Ints(result["odd"]) + sort.Ints(result["even"]) + + return result, nil +} + +// additional method for printing the result of SeparateOddEven. +// this prints to an io.Writer instead of the standard output +// to enable easy testing of the function. +func PrintOddEvenMap(w io.Writer, result map[string][]int) { + fmt.Fprintf(w, "Odd numbers: %v\n", result["odd"]) + fmt.Fprintf(w, "Even numbers: %v\n", result["even"]) +} diff --git a/pkg/learn-go/2024219/odd_even_separator/odd_even_separator_test.go b/pkg/learn-go/2024219/odd_even_separator/odd_even_separator_test.go new file mode 100644 index 0000000..4610975 --- /dev/null +++ b/pkg/learn-go/2024219/odd_even_separator/odd_even_separator_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "reflect" + "testing" +) + +// TestSeparateOddEven tests the SeparateOddEven function with various test cases. +func TestSeparateOddEven(t *testing.T) { + tests := []struct { + name string + jsonInput string + expected map[string][]int + expectedError bool + }{ + { + name: "Valid input with mixed odd and even numbers", + jsonInput: `[1, 4, 5, 8, 9, 2, 11, 14, 7]`, + expected: map[string][]int{ + "even": {2, 4, 8, 14}, + "odd": {1, 5, 7, 9, 11}, + }, + expectedError: false, + }, + { + name: "All even numbers", + jsonInput: `[2, 4, 6, 8, 10]`, + expected: map[string][]int{ + "even": {2, 4, 6, 8, 10}, + "odd": {}, + }, + expectedError: false, + }, + { + name: "All odd numbers", + jsonInput: `[1, 3, 5, 7, 9]`, + expected: map[string][]int{ + "even": {}, + "odd": {1, 3, 5, 7, 9}, + }, + expectedError: false, + }, + { + name: "Empty input", + jsonInput: `[]`, + expected: map[string][]int{"even": {}, "odd": {}}, + expectedError: false, + }, + { + name: "Invalid JSON input", + jsonInput: `[1, 2, "abc"]`, // Invalid JSON + expected: nil, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := SeparateOddEven(tt.jsonInput) + if (err != nil) != tt.expectedError { + t.Errorf("SeparateOddEven() error = %v, expectedError %v", err, tt.expectedError) + return + } + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("SeparateOddEven() = %v, expected %v", result, tt.expected) + } + }) + } +} + +// TestPrintOddEvenMap tests the PrintOddEvenMap function by capturing the output to a buffer. +func TestPrintOddEvenMap(t *testing.T) { + tests := []struct { + description string + input map[string][]int + expectedOutput string + }{ + { + description: "Mixed odd and even numbers", + input: map[string][]int{ + "odd": {1, 3, 5}, + "even": {2, 4, 6}, + }, + expectedOutput: "Odd numbers: [1 3 5]\nEven numbers: [2 4 6]\n", + }, + { + description: "Only even numbers", + input: map[string][]int{ + "odd": {}, + "even": {2, 4, 6}, + }, + expectedOutput: "Odd numbers: []\nEven numbers: [2 4 6]\n", + }, + { + description: "Only odd numbers", + input: map[string][]int{ + "odd": {1, 3, 5}, + "even": {}, + }, + expectedOutput: "Odd numbers: [1 3 5]\nEven numbers: []\n", + }, + { + description: "Empty map", + input: map[string][]int{ + "odd": {}, + "even": {}, + }, + expectedOutput: "Odd numbers: []\nEven numbers: []\n", + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + var output bytes.Buffer + PrintOddEvenMap(&output, tc.input) + + if output.String() != tc.expectedOutput { + t.Errorf("got %q, expected %q", output.String(), tc.expectedOutput) + } + }) + } +} diff --git a/pkg/learn-go/2112107/evenOdd.go b/pkg/learn-go/2112107/evenOdd.go new file mode 100644 index 0000000..eaeaa40 --- /dev/null +++ b/pkg/learn-go/2112107/evenOdd.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func main() { + var jsonDatStr = "[1,2,3,4,5,6,8,9,0,10,11]" + + fmt.Print(getSortedIntegerSlices(jsonDatStr)) +} + +func getSortedIntegerSlices(inputStr string) map[string][]int { + var inputArray []int + json.Unmarshal([]byte(inputStr), &inputArray) + + var even []int + var odd []int + + for _, e := range inputArray { + if e%2 == 0 { + even = append(even, e) + } else { + odd = append(odd, e) + } + sort.Ints(even) + sort.Ints(odd) + } + + return map[string][]int{ + "even": even, + "odd": odd, + } +} diff --git a/pkg/learn-go/2112107/evenOdd_test.go b/pkg/learn-go/2112107/evenOdd_test.go new file mode 100644 index 0000000..1e23433 --- /dev/null +++ b/pkg/learn-go/2112107/evenOdd_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestGetSortedIntegerSlices(t *testing.T) { + testCaseMap := map[string]struct { + input string + even []int + odd []int + }{ + "testA": { + input: "[4,3,2,1]", + even: []int{2, 4}, + odd: []int{1, 3}, + }, + "testB": { + input: "[1,2,3,4,5,67]", + even: []int{2, 4}, + odd: []int{1, 3, 5, 67}, + }, + } + + for name, testCase := range testCaseMap { + t.Run(name, func(t *testing.T) { + + result := getSortedIntegerSlices(testCase.input) + + if !reflect.DeepEqual(result["even"], testCase.even) { + t.Errorf("For input %v: expected even %v, got %v", testCase.input, testCase.even, result["even"]) + } + + if !reflect.DeepEqual(result["odd"], testCase.odd) { + t.Errorf("For input %v: expected odd %v, got %v", testCase.input, testCase.odd, result["odd"]) + } + }) + } + +} diff --git a/pkg/learn-go/2112107/go.mod b/pkg/learn-go/2112107/go.mod new file mode 100644 index 0000000..c1c5b60 --- /dev/null +++ b/pkg/learn-go/2112107/go.mod @@ -0,0 +1,3 @@ +module 2112107 + +go 1.19 diff --git a/pkg/learn-go/2121190/go.mod b/pkg/learn-go/2121190/go.mod new file mode 100644 index 0000000..fa3c192 --- /dev/null +++ b/pkg/learn-go/2121190/go.mod @@ -0,0 +1,3 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/learn-go/2121190 + +go 1.23.1 diff --git a/pkg/learn-go/2121190/integers.json b/pkg/learn-go/2121190/integers.json new file mode 100644 index 0000000..75b15ec --- /dev/null +++ b/pkg/learn-go/2121190/integers.json @@ -0,0 +1,15 @@ +{ + "integers": [ + 0, + 1, + 2, + 3, + 4, + 10, + 6, + 7, + 8, + 9, + 5 + ] +} \ No newline at end of file diff --git a/pkg/learn-go/2121190/main.go b/pkg/learn-go/2121190/main.go new file mode 100644 index 0000000..5585ddf --- /dev/null +++ b/pkg/learn-go/2121190/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/learn-go/2121190/odd_and_even" +) + +func main() { + numbers, error := odd_and_even.GetArrayFromJSON("./integers.json") + + if error != nil { + fmt.Println(error) + } else { + numbersMap := odd_and_even.ConvertArrayToMapAndSort(numbers) + fmt.Println(numbersMap) + } + +} diff --git a/pkg/learn-go/2121190/odd_and_even/odd_and_even.go b/pkg/learn-go/2121190/odd_and_even/odd_and_even.go new file mode 100644 index 0000000..cc5dd63 --- /dev/null +++ b/pkg/learn-go/2121190/odd_and_even/odd_and_even.go @@ -0,0 +1,56 @@ +package odd_and_even + +import ( + "encoding/json" + "errors" + "io" + "os" + "sort" +) + +type Integers struct { + Integers []int `json:"integers"` +} + +func GetArrayFromJSON(filePath string) ([]int, error) { + jsonFile, error := os.Open(filePath) + + if error != nil { + jsonFile.Close() + return nil, errors.New("File cannot be opened.") + } else { + defer jsonFile.Close() // Close after this function returns + + byteValue, _ := io.ReadAll(jsonFile) + var integers Integers + + json.Unmarshal(byteValue, &integers) + + return integers.Integers, nil + } +} + +func isEven(number int) bool { + return number%2 == 0 +} + +func appendAndSort(numbers []int, number int) []int { + newNumbers := append(numbers, number) + sort.Ints(newNumbers) + return newNumbers +} + +func ConvertArrayToMapAndSort(numbers []int) map[string][]int { + sortedNumbersMap := make(map[string][]int) + sortedNumbersMap["odd"] = make([]int, 0) + sortedNumbersMap["even"] = make([]int, 0) + + for _, number := range numbers { + if isEven(number) { + sortedNumbersMap["even"] = appendAndSort(sortedNumbersMap["even"], number) + } else { + sortedNumbersMap["odd"] = appendAndSort(sortedNumbersMap["odd"], number) + } + } + return sortedNumbersMap +} diff --git a/pkg/learn-go/2121190/odd_and_even/odd_and_even_test.go b/pkg/learn-go/2121190/odd_and_even/odd_and_even_test.go new file mode 100644 index 0000000..57ebb93 --- /dev/null +++ b/pkg/learn-go/2121190/odd_and_even/odd_and_even_test.go @@ -0,0 +1,97 @@ +package odd_and_even + +import ( + "reflect" + "testing" +) + +func TestGetArrayFromJSONValidPath(t *testing.T) { + // GIVEN + filePath := "../integers.json" + + // WHEN + numbers, error := GetArrayFromJSON(filePath) + + // THEN + expectedNumbers := []int{0, 1, 2, 3, 4, 10, 6, 7, 8, 9, 5} + + if !reflect.DeepEqual(numbers, expectedNumbers) { + t.Errorf("Numbers not as expected.\n Expected: %v\n Received: %v\n", expectedNumbers, numbers) + } + + if error != nil { + t.Errorf("Expected no error to be thrown.\n Error: %v\n", error) + } +} + +func TestGetArrayFromJSONEmptyPath(t *testing.T) { + // GIVEN + filePath := "" + + // WHEN + numbers, error := GetArrayFromJSON(filePath) + + // THEN + + if numbers != nil { + t.Errorf("Numbers not as expected.\n Expected: %v\n Received: %v\n", nil, numbers) + } + + if error == nil { + t.Errorf("Expected error to be thrown.\n") + } +} + +func TestConvertArrayToMapAndSortArrayAlreadySorted(t *testing.T) { + // GIVEN + numbers := []int{0, 1, 2, 3, 4, 5} + + // WHEN + numbersMap := ConvertArrayToMapAndSort(numbers) + + // THEN + expectedMap := map[string][]int{ + "even": []int{0, 2, 4}, + "odd": []int{1, 3, 5}, + } + + if !reflect.DeepEqual(numbersMap, expectedMap) { + t.Errorf("NumbersMap not as expected.\n Expected: %v\n Received: %v\n", expectedMap, numbersMap) + } +} + +func TestConvertArrayToMapAndSortArrayNotSorted(t *testing.T) { + // GIVEN + numbers := []int{9, 5, 7, 1, 3, 2} + + // WHEN + numbersMap := ConvertArrayToMapAndSort(numbers) + + // THEN + expectedMap := map[string][]int{ + "even": []int{2}, + "odd": []int{1, 3, 5, 7, 9}, + } + + if !reflect.DeepEqual(numbersMap, expectedMap) { + t.Errorf("NumbersMap not as expected.\n Expected: %v\n Received: %v\n", expectedMap, numbersMap) + } +} + +func TestConvertArrayToMapAndSortArrayEmpty(t *testing.T) { + // GIVEN + numbers := []int{} + + // WHEN + numbersMap := ConvertArrayToMapAndSort(numbers) + + // THEN + expectedMap := map[string][]int{ + "even": []int{}, + "odd": []int{}, + } + + if !reflect.DeepEqual(numbersMap, expectedMap) { + t.Errorf("NumbersMap not as expected.\n Expected: %v\n Received: %v\n", expectedMap, numbersMap) + } +} diff --git a/pkg/learn-go/2121321/go.mod b/pkg/learn-go/2121321/go.mod new file mode 100644 index 0000000..9151ec7 --- /dev/null +++ b/pkg/learn-go/2121321/go.mod @@ -0,0 +1,3 @@ +module oddEven + +go 1.23.2 diff --git a/pkg/learn-go/2121321/main.go b/pkg/learn-go/2121321/main.go new file mode 100644 index 0000000..013eb1e --- /dev/null +++ b/pkg/learn-go/2121321/main.go @@ -0,0 +1,7 @@ +package main + +import "oddEven/oddEven" + +func main() { + oddEven.OddEven() +} diff --git a/pkg/learn-go/2121321/numbers.json b/pkg/learn-go/2121321/numbers.json new file mode 100644 index 0000000..d692b19 --- /dev/null +++ b/pkg/learn-go/2121321/numbers.json @@ -0,0 +1,3 @@ +{ + "numbers" : [4, 1, 3, 7, 6, 9, 0, 8, 5, 2] +} diff --git a/pkg/learn-go/2121321/oddEven/odd-even.go b/pkg/learn-go/2121321/oddEven/odd-even.go new file mode 100644 index 0000000..d71a02e --- /dev/null +++ b/pkg/learn-go/2121321/oddEven/odd-even.go @@ -0,0 +1,61 @@ +package oddEven + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" +) + +type Numbers struct { + Numbers []int `json:"numbers"` +} + +func OddEven() (map[string][]int, error) { + filepath, err := filepath.Abs("./numbers.json") + if err != nil { + fmt.Println(err) + } + jsonFile, err := os.Open(filepath) + defer jsonFile.Close() + + if err != nil { + fmt.Println(err) + } + + byteArray, _ := io.ReadAll(jsonFile) + + var numbersjson Numbers + + json.Unmarshal(byteArray, &numbersjson) + + var list []int + list = numbersjson.Numbers + + var odd []int + var even []int + + for i := 0; i < len(list); i++ { + if list[i]%2 == 0 { + even = append(even, list[i]) + } else { + odd = append(odd, list[i]) + } + } + + slices.Sort(odd) + slices.Sort(even) + + result := map[string][]int{ + "odd": odd, + "even": even, + } + + fmt.Println(result) + + fmt.Println(result["odd"]) + + return result, nil +} diff --git a/pkg/learn-go/2121321/oddEven/odd-even_test.go b/pkg/learn-go/2121321/oddEven/odd-even_test.go new file mode 100644 index 0000000..144fc65 --- /dev/null +++ b/pkg/learn-go/2121321/oddEven/odd-even_test.go @@ -0,0 +1,37 @@ +package oddEven + +import ( + "os" + "reflect" + "testing" +) + +func TestEvenOdd(t *testing.T) { + err := os.Chdir("..") + if err != nil { + panic(err) + } + is, err := OddEven() + if err != nil { + t.Log(err) + t.Fail() + } + + should := map[string][]int{ + "odd": []int{1, 3, 5, 7, 9}, + "even": []int{0, 2, 4, 6, 8}, + } + + if !reflect.DeepEqual(is, should) { + t.Logf("Expected : %v", should["odd"]) + + for k := range is { + t.Log(k) + t.Log(is[k]) + + } + t.Logf("Was: %v", is["odd"]) + t.Fail() + + } +} diff --git a/pkg/learn-go/2122245/go.mod b/pkg/learn-go/2122245/go.mod new file mode 100644 index 0000000..e81a0be --- /dev/null +++ b/pkg/learn-go/2122245/go.mod @@ -0,0 +1,3 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/learn-go/2122245 + +go 1.23.1 diff --git a/pkg/learn-go/2122245/main.go b/pkg/learn-go/2122245/main.go new file mode 100644 index 0000000..61e74e0 --- /dev/null +++ b/pkg/learn-go/2122245/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/learn-go/2122245/parity" +) + +func main() { + numbers := "[1, 5, 3, -8, 2, -3, 4, 5, -5, 0, 42]" + seperatedNumbers, err := parity.SeperateEvenAndOddNumbers(numbers) + + if err != nil { + fmt.Printf("Something went wrong: %s\n", err) + return + } + + fmt.Println(seperatedNumbers) +} diff --git a/pkg/learn-go/2122245/parity/parity_seperator.go b/pkg/learn-go/2122245/parity/parity_seperator.go new file mode 100644 index 0000000..8a3886d --- /dev/null +++ b/pkg/learn-go/2122245/parity/parity_seperator.go @@ -0,0 +1,32 @@ +package parity + +import ( + "encoding/json" + "slices" +) + +func SeperateEvenAndOddNumbers(jsonString string) (map[string][]int, error) { + var numbers []int + err := json.Unmarshal([]byte(jsonString), &numbers) + if err != nil { + return nil, err + } + + seperatedNumbers := map[string][]int{ + "even": make([]int, 0), + "odd": make([]int, 0), + } + + for _, number := range numbers { + parity := "odd" + if number%2 == 0 { + parity = "even" + } + seperatedNumbers[parity] = append(seperatedNumbers[parity], number) + } + + slices.Sort(seperatedNumbers["odd"]) + slices.Sort(seperatedNumbers["even"]) + + return seperatedNumbers, nil +} diff --git a/pkg/learn-go/2122245/parity/parity_seperator_test.go b/pkg/learn-go/2122245/parity/parity_seperator_test.go new file mode 100644 index 0000000..afe5d41 --- /dev/null +++ b/pkg/learn-go/2122245/parity/parity_seperator_test.go @@ -0,0 +1,53 @@ +package parity + +import ( + "fmt" + "reflect" + "testing" +) + +func TestParitySeperator(t *testing.T) { + test_params := []struct { + input string + expected_output map[string][]int + }{ + {"[9, 5, -5, 5, 1, 2]", + map[string][]int{ + "even": {2}, + "odd": {-5, 1, 5, 5, 9}, + }, + }, + {"[4, 6, -2]", + map[string][]int{ + "even": {-2, 4, 6}, + "odd": {}, + }, + }, + {"[9, -7, 1]", + map[string][]int{ + "even": {}, + "odd": {-7, 1, 9}, + }, + }, + + {"[]", + map[string][]int{ + "even": {}, + "odd": {}, + }, + }, + } + + for _, test_param := range test_params { + t.Run(fmt.Sprintf("Test with params %v", test_param), func(t *testing.T) { + output, err := SeperateEvenAndOddNumbers(test_param.input) + if err != nil { + t.Fatalf("Error: '%v'", err) + } + + if !reflect.DeepEqual(output, test_param.expected_output) { + t.Fatalf("Expected '%v', but got '%v'", test_param.expected_output, output) + } + }) + } +} diff --git a/pkg/learn-go/2123801/go.mod b/pkg/learn-go/2123801/go.mod new file mode 100644 index 0000000..f86f315 --- /dev/null +++ b/pkg/learn-go/2123801/go.mod @@ -0,0 +1,3 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/learn-go/2123801 + +go 1.23.1 diff --git a/pkg/learn-go/2123801/main.go b/pkg/learn-go/2123801/main.go new file mode 100644 index 0000000..e8dc354 --- /dev/null +++ b/pkg/learn-go/2123801/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func SortOddEven(jsonData string) (map[string][]int, error) { + var numbers []int + err := json.Unmarshal([]byte(jsonData), &numbers) + if err != nil { + return nil, err + } + + result := map[string][]int{ + "odd": {}, + "even": {}, + } + + for _, num := range numbers { + if num%2 == 0 { + result["even"] = append(result["even"], num) + } else { + result["odd"] = append(result["odd"], num) + } + } + + sort.Ints(result["odd"]) + sort.Ints(result["even"]) + + return result, nil +} + +func main() { + jsonData := `[5, 8, 3, 10, 1, 12, 9]` + result, err := SortOddEven(jsonData) + if err != nil { + fmt.Println("Error:", err) + return + } + fmt.Printf("Ergebnis: %v\n", result) +} diff --git a/pkg/learn-go/2123801/odd_even_sort_test.go b/pkg/learn-go/2123801/odd_even_sort_test.go new file mode 100644 index 0000000..991eaed --- /dev/null +++ b/pkg/learn-go/2123801/odd_even_sort_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestSortOddEven(t *testing.T) { + tests := []struct { + input string + expected map[string][]int + hasError bool + }{ + { + input: `[5, 8, 3, 10, 1, 12, 9]`, + expected: map[string][]int{ + "odd": {1, 3, 5, 9}, + "even": {8, 10, 12}, + }, + hasError: false, + }, + { + input: `[2, 4, 6]`, + expected: map[string][]int{ + "odd": {}, + "even": {2, 4, 6}, + }, + hasError: false, + }, + { + input: `[1, 3, 5]`, + expected: map[string][]int{ + "odd": {1, 3, 5}, + "even": {}, + }, + hasError: false, + }, + { + input: `[invalid-json]`, + expected: nil, + hasError: true, + }, + } + + for _, test := range tests { + result, err := SortOddEven(test.input) + + if test.hasError { + if err == nil { + t.Errorf("erwarteter Fehler für Eingabe %v, erhielt nil", test.input) + } + continue + } + + if err != nil { + t.Errorf("erwartete keinen Fehler für Eingabe %v, erhielt %v", test.input, err) + } + + if !reflect.DeepEqual(result, test.expected) { + t.Errorf("für Eingabe %v, erwartete %v, erhielt %v", test.input, test.expected, result) + } + } +} diff --git a/pkg/learn-go/2210788/even_odd.go b/pkg/learn-go/2210788/even_odd.go new file mode 100644 index 0000000..c15c380 --- /dev/null +++ b/pkg/learn-go/2210788/even_odd.go @@ -0,0 +1,57 @@ +package evenOddParrity + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sort" +) + +func JSONToOddEvenMap() { + EvenOdd("integers.json") +} + +func EvenOdd(filePath string) { + // Open the JSON file + file, err := os.Open(filePath) + if err != nil { + log.Fatal(err) + } + defer file.Close() + + // Decode the JSON data + var data map[string][]int + decoder := json.NewDecoder(file) + err = decoder.Decode(&data) + if err != nil { + log.Fatal(err) + } + + // Assume the JSON data contains a key "integers" with an array of integers + integers, ok := data["integers"] + if !ok { + log.Fatal("Key 'integers' not found in JSON") + } + + // Sort the integers into even and odd + sorted := parity(integers) + fmt.Printf("Even numbers: %v\n", sorted["even"]) + fmt.Printf("Odd numbers: %v\n", sorted["odd"]) +} + +func parity(integers []int) map[string][]int { + sorted := make(map[string][]int) + sorted["even"] = []int{} + sorted["odd"] = []int{} + for _, integer := range integers { + if integer%2 == 0 { + sorted["even"] = append(sorted["even"], integer) + } else { + sorted["odd"] = append(sorted["odd"], integer) + } + } + sort.Ints(sorted["even"]) + sort.Ints(sorted["odd"]) + return sorted +} diff --git a/pkg/learn-go/2210788/even_odd_test.go b/pkg/learn-go/2210788/even_odd_test.go new file mode 100644 index 0000000..480f368 --- /dev/null +++ b/pkg/learn-go/2210788/even_odd_test.go @@ -0,0 +1,113 @@ +package evenOddParrity + +import ( + "bytes" + "encoding/json" + "os" + "testing" +) + +func TestEvenOdd(t *testing.T) { + tests := []struct { + name string + input map[string][]int + expectedOutput string + }{ + { + name: "Mixed even and odd numbers", + input: map[string][]int{ + "integers": {1, 2, 3, 4, 5, 6}, + }, + expectedOutput: "Even numbers: [2 4 6]\nOdd numbers: [1 3 5]\n", + }, + { + name: "Only even numbers", + input: map[string][]int{ + "integers": {2, 4, 6, 8, 10}, + }, + expectedOutput: "Even numbers: [2 4 6 8 10]\nOdd numbers: []\n", + }, + { + name: "Only odd numbers", + input: map[string][]int{ + "integers": {1, 3, 5, 7, 9}, + }, + expectedOutput: "Even numbers: []\nOdd numbers: [1 3 5 7 9]\n", + }, + { + name: "Empty list", + input: map[string][]int{ + "integers": {}, + }, + expectedOutput: "Even numbers: []\nOdd numbers: []\n", + }, + { + name: "Single even number", + input: map[string][]int{ + "integers": {2}, + }, + expectedOutput: "Even numbers: [2]\nOdd numbers: []\n", + }, + { + name: "Single odd number", + input: map[string][]int{ + "integers": {1}, + }, + expectedOutput: "Even numbers: []\nOdd numbers: [1]\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock JSON content + mockJSON, err := json.Marshal(tt.input) + if err != nil { + t.Fatalf("Failed to marshal mock data: %v", err) + } + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "integers.json") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write mock JSON content to the temporary file + if _, err := tmpFile.Write(mockJSON); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("Failed to close temp file: %v", err) + } + + // Redirect stdout to a pipe + r, w, _ := os.Pipe() + old := os.Stdout + os.Stdout = w + defer func() { + os.Stdout = old + r.Close() + }() + + // Create a buffer to capture the output + var buf bytes.Buffer + done := make(chan bool) + go func() { + buf.ReadFrom(r) + done <- true + }() + + // Call the evenOdd function + EvenOdd(tmpFile.Name()) + + // Close the write end of the pipe and wait for the reading to complete + w.Close() + <-done + + // Verify the output + if buf.String() != tt.expectedOutput { + t.Errorf("Expected output:\n%s\nGot:\n%s", tt.expectedOutput, buf.String()) + } + }) + } +} diff --git a/pkg/learn-go/2210788/go.mod b/pkg/learn-go/2210788/go.mod new file mode 100644 index 0000000..aca71da --- /dev/null +++ b/pkg/learn-go/2210788/go.mod @@ -0,0 +1,3 @@ +module sortNumbers + +go 1.23.1 diff --git a/pkg/learn-go/2210788/integers.json b/pkg/learn-go/2210788/integers.json new file mode 100644 index 0000000..7debe23 --- /dev/null +++ b/pkg/learn-go/2210788/integers.json @@ -0,0 +1,3 @@ +{ + "integers": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +} \ No newline at end of file diff --git a/pkg/learn-go/2210806/go.mod b/pkg/learn-go/2210806/go.mod new file mode 100644 index 0000000..a6ab650 --- /dev/null +++ b/pkg/learn-go/2210806/go.mod @@ -0,0 +1,3 @@ +module 2210806 + +go 1.23.1 \ No newline at end of file diff --git a/pkg/learn-go/2210806/main.go b/pkg/learn-go/2210806/main.go new file mode 100644 index 0000000..c33758c --- /dev/null +++ b/pkg/learn-go/2210806/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sort" +) + +// ein "struct" um die geraden und ungeraden Zahlen zu speichern +type Result struct { + Odd []int `json:"odd"` + Even []int `json:"even"` +} + +// oddAndEven nimmt ein JSON Array und gibt ein "Result" zuruck, mit sortierten geraden und ungeraden Zahlen. +func oddAndEven(jsonData []byte) (Result, error) { + var numbers []int + if err := json.Unmarshal(jsonData, &numbers); err != nil { + return Result{}, err + } + + odd := []int{} + even := []int{} + + for _, num := range numbers { + if num%2 == 0 { + even = append(even, num) + } else { + odd = append(odd, num) + } + } + + sort.Ints(odd) + sort.Ints(even) + + return Result{Odd: odd, Even: even}, nil +} + +func main() { + jsonInput := os.Args[1] + + result, err := oddAndEven([]byte(jsonInput)) + if err != nil { + log.Fatalf("Error processing numbers: %v", err) + } + + resultJSON, _ := json.Marshal(result) + fmt.Println(string(resultJSON)) +} diff --git a/pkg/learn-go/2210806/main_test.go b/pkg/learn-go/2210806/main_test.go new file mode 100644 index 0000000..ae282a9 --- /dev/null +++ b/pkg/learn-go/2210806/main_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestProcessNumbers(t *testing.T) { + tests := []struct { + input []int + expected Result + }{ + { + input: []int{1, 2, 3, 4, 5, 6}, + expected: Result{Odd: []int{1, 3, 5}, Even: []int{2, 4, 6}}, + }, + { + input: []int{7, 8, 9, 10, 11, 12}, + expected: Result{Odd: []int{7, 9, 11}, Even: []int{8, 10, 12}}, + }, + { + input: []int{0, -1, -2, -3}, + expected: Result{Odd: []int{-3, -1}, Even: []int{-2, 0}}, + }, + } + + for _, test := range tests { + jsonInput, _ := json.Marshal(test.input) + result, err := oddAndEven(jsonInput) + if err != nil { + t.Errorf("Error processing input %v: %v", test.input, err) + } + if !reflect.DeepEqual(result, test.expected) { + t.Errorf("For input %v, expected %v, but got %v", test.input, test.expected, result) + } + } +} diff --git a/pkg/learn-go/2210943/main b/pkg/learn-go/2210943/main new file mode 100755 index 0000000..a0dc48c Binary files /dev/null and b/pkg/learn-go/2210943/main differ diff --git a/pkg/learn-go/2210943/main.go b/pkg/learn-go/2210943/main.go new file mode 100644 index 0000000..ee5c6b6 --- /dev/null +++ b/pkg/learn-go/2210943/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +// ProcessNumbers processes a JSON string of integers +func ProcessNumbers(jsonInput string) (map[string][]int, error) { + type Input struct { + Numbers []int `json:"numbers"` + } + + var input Input + err := json.Unmarshal([]byte(jsonInput), &input) + if err != nil { + return nil, err + } + + numberMap := map[string][]int{ + "odd": {}, + "even": {}, + } + + for _, num := range input.Numbers { + if num%2 == 0 { + numberMap["even"] = append(numberMap["even"], num) + } else { + numberMap["odd"] = append(numberMap["odd"], num) + } + } + + sort.Ints(numberMap["odd"]) + sort.Ints(numberMap["even"]) + + return numberMap, nil +} + +func main() { + jsonInput := `{"numbers": [34, 21, 7, 18, 5, 12, 9, 1, 56, 4]}` + numberMap, err := ProcessNumbers(jsonInput) + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println("Sorted Map of Odd and Even Numbers:") + fmt.Printf("Odd numbers: %v\n", numberMap["odd"]) + fmt.Printf("Even numbers: %v\n", numberMap["even"]) +} diff --git a/pkg/learn-go/2210943/main_test.go b/pkg/learn-go/2210943/main_test.go new file mode 100644 index 0000000..bb84434 --- /dev/null +++ b/pkg/learn-go/2210943/main_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "reflect" + "testing" +) + +// Test ProcessNumbers function +func TestProcessNumbers(t *testing.T) { + jsonInput := `{"numbers": [34, 21, 7, 18, 5, 12, 9, 1, 56, 4]}` + expected := map[string][]int{ + "odd": {1, 5, 7, 9, 21}, + "even": {4, 12, 18, 34, 56}, + } + + result, err := ProcessNumbers(jsonInput) + if err != nil { + t.Fatalf("Error occurred: %v", err) + } + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, but got %v", expected, result) + } +} + +// Test ProcessNumbers with empty input +func TestProcessNumbersEmpty(t *testing.T) { + jsonInput := `{"numbers": []}` + expected := map[string][]int{ + "odd": {}, + "even": {}, + } + + result, err := ProcessNumbers(jsonInput) + if err != nil { + t.Fatalf("Error occurred: %v", err) + } + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, but got %v", expected, result) + } +} + +// Test ProcessNumbers with all odd numbers +func TestProcessNumbersAllOdd(t *testing.T) { + jsonInput := `{"numbers": [1, 3, 5, 7, 9]}` + expected := map[string][]int{ + "odd": {1, 3, 5, 7, 9}, + "even": {}, + } + + result, err := ProcessNumbers(jsonInput) + if err != nil { + t.Fatalf("Error occurred: %v", err) + } + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, but got %v", expected, result) + } +} + +// Test ProcessNumbers with all even numbers +func TestProcessNumbersAllEven(t *testing.T) { + jsonInput := `{"numbers": [2, 4, 6, 8, 10]}` + expected := map[string][]int{ + "odd": {}, + "even": {2, 4, 6, 8, 10}, + } + + result, err := ProcessNumbers(jsonInput) + if err != nil { + t.Fatalf("Error occurred: %v", err) + } + + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, but got %v", expected, result) + } +} diff --git a/pkg/learn-go/2212765/go.mod b/pkg/learn-go/2212765/go.mod new file mode 100644 index 0000000..fc1936d --- /dev/null +++ b/pkg/learn-go/2212765/go.mod @@ -0,0 +1,3 @@ +module 2212765 + +go 1.23.1 diff --git a/pkg/learn-go/2212765/main.go b/pkg/learn-go/2212765/main.go new file mode 100644 index 0000000..1eba1bf --- /dev/null +++ b/pkg/learn-go/2212765/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "2212765/numbers" + + "fmt" + "os" +) + +func main() { + json, error := os.ReadFile("numbers.json") + if error != nil { + fmt.Println(error) + return + } + + numbersMap, error := numbers.JSONToOddEvenMap(string(json)) + if error != nil { + fmt.Println("JSONToOddEvenMap returned error: ", error) + return + } + + fmt.Printf("Even: %v\nOdd: %v\n", numbersMap["even"], numbersMap["odd"]) +} diff --git a/pkg/learn-go/2212765/numbers.json b/pkg/learn-go/2212765/numbers.json new file mode 100644 index 0000000..67ebd0d --- /dev/null +++ b/pkg/learn-go/2212765/numbers.json @@ -0,0 +1 @@ +[8, 1, 42, 99, 69, 88, 3, 7] diff --git a/pkg/learn-go/2212765/numbers/numbers.go b/pkg/learn-go/2212765/numbers/numbers.go new file mode 100644 index 0000000..e7a21f2 --- /dev/null +++ b/pkg/learn-go/2212765/numbers/numbers.go @@ -0,0 +1,31 @@ +package numbers + +import ( + "encoding/json" + "sort" +) + +func JSONToOddEvenMap(jsonNumbers string) (map[string][]int, error) { + var numbers []int + + if error := json.Unmarshal([]byte(jsonNumbers), &numbers); error != nil { + return nil, error + } + + result := map[string][]int{ + "even": {}, + "odd": {}, + } + + sort.Ints(numbers) + + for _, number := range numbers { + if number%2 == 0 { + result["even"] = append(result["even"], number) + } else { + result["odd"] = append(result["odd"], number) + } + } + + return result, nil +} diff --git a/pkg/learn-go/2212765/numbers/numbers_test.go b/pkg/learn-go/2212765/numbers/numbers_test.go new file mode 100644 index 0000000..a5837f5 --- /dev/null +++ b/pkg/learn-go/2212765/numbers/numbers_test.go @@ -0,0 +1,55 @@ +package numbers + +import ( + "encoding/json" + "testing" +) + +type TestCase struct { + input string + expected string +} + +var testCases = []TestCase{ + { + input: "[1, 2, 3, 4, 5]", + expected: `{"even":[2,4],"odd":[1,3,5]}`, + }, + { + input: "[5, 4, 3, 2, 1]", + expected: `{"even":[2,4],"odd":[1,3,5]}`, + }, + { + input: "[1, 3, 5, 7, 9]", + expected: `{"even":[],"odd":[1,3,5,7,9]}`, + }, + { + input: "[2, 4, 6, 8, 10]", + expected: `{"even":[2,4,6,8,10],"odd":[]}`, + }, + { + input: "[]", + expected: `{"even":[],"odd":[]}`, + }, +} + +func TestJSONToOddEvenMap(t *testing.T) { + for _, testCase := range testCases { + result, error := JSONToOddEvenMap(testCase.input) + + if error != nil { + t.Errorf("JSONToOddEvenMap(%q) returned error: %v", + testCase.input, + error) + } + + resultJSON, _ := json.Marshal(result) + + if string(resultJSON) != testCase.expected { + t.Errorf("JSONToOddEvenMap(%q) returned %s, but %s was expected", + testCase.input, + resultJSON, + testCase.expected) + } + } +} diff --git a/pkg/learn-go/3000766/oddeven_marvin.go b/pkg/learn-go/3000766/oddeven_marvin.go new file mode 100644 index 0000000..bedae5e --- /dev/null +++ b/pkg/learn-go/3000766/oddeven_marvin.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func main() { + input := `[5, 3, 8, 1, 4, 10, 9]` + result, err := NumberProcess(input) + if err != nil { + fmt.Println("Error:", err) + return + } + fmt.Println(result) +} + +func NumberProcess(numbers string) (map[string][]int, error) { + var intArr []int + err := json.Unmarshal([]byte(numbers), &intArr) + if err != nil { + return nil, err + } + + result := map[string][]int{ + "odd": {}, + "even": {}, + } + + for _, num := range intArr { + if num%2 == 0 { + result["even"] = append(result["even"], num) + } else { + result["odd"] = append(result["odd"], num) + } + } + + sort.Ints(result["odd"]) + sort.Ints(result["even"]) + + return result, nil +} diff --git a/pkg/learn-go/3000766/oddeven_marvin_test.go b/pkg/learn-go/3000766/oddeven_marvin_test.go new file mode 100644 index 0000000..e57e439 --- /dev/null +++ b/pkg/learn-go/3000766/oddeven_marvin_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestJSONtoOddEvenMap(t *testing.T) { + tests := []struct { + input string + expected map[string][]int + hasError bool + }{ + { + input: `[1, 2, 3, 4, 5]`, + expected: map[string][]int{ + "odd": {1, 3, 5}, + "even": {2, 4}, + }, + hasError: false, + }, + { + input: `[-1, -2, 0, 7, 8]`, + expected: map[string][]int{ + "odd": {-1, 7}, + "even": {-2, 0, 8}, + }, + hasError: false, + }, + { + input: `[1, "abc", 3]`, // Fehlerfall: falsches JSON + expected: nil, + hasError: true, + }, + { + input: `[]`, + expected: map[string][]int{ + "odd": {}, + "even": {}, + }, + hasError: false, + }, + } + + for _, test := range tests { + result, err := NumberProcess(test.input) + if (err != nil) != test.hasError { + t.Errorf("Fehler erwartet = %v, erhalten = %v", test.hasError, err) + } + if !reflect.DeepEqual(result, test.expected) { + t.Errorf("Erwartet %v, erhalten %v für Eingabe %v", test.expected, result, test.input) + } + } +} diff --git a/pkg/learn-go/3001302/go.mod b/pkg/learn-go/3001302/go.mod new file mode 100644 index 0000000..bf90551 --- /dev/null +++ b/pkg/learn-go/3001302/go.mod @@ -0,0 +1,3 @@ +module 3001302 + +go 1.22.5 diff --git a/pkg/learn-go/3001302/main.go b/pkg/learn-go/3001302/main.go new file mode 100644 index 0000000..719758c --- /dev/null +++ b/pkg/learn-go/3001302/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "3001302/numbers" + "encoding/json" + "fmt" + "os" +) + +func main() { + // Open the numbers.json file + data, err := os.ReadFile("./numbers/numbers.json") + if err != nil { + fmt.Println(err) + return + } + + // Unmarshal the JSON data + var nums []int + err = json.Unmarshal(data, &nums) + if err != nil { + fmt.Println(err) + return + } + + result := numbers.CategorizeNumbers(nums) + + numbers.SortNumbers(result) + + // Print the result + fmt.Printf("Unmarshaled: even:[%s] odd:[%s]\n", + numbers.FormatSlice(result["even"]), + numbers.FormatSlice(result["odd"])) +} diff --git a/pkg/learn-go/3001302/numbers/numbers.go b/pkg/learn-go/3001302/numbers/numbers.go new file mode 100644 index 0000000..d781ec8 --- /dev/null +++ b/pkg/learn-go/3001302/numbers/numbers.go @@ -0,0 +1,37 @@ +package numbers + +import ( + "fmt" + "sort" +) + +// separate integers by comma +func FormatSlice(s []int) string { + var result string + for i, v := range s { + if i > 0 { + result += ", " + } + result += fmt.Sprintf("%d", v) + } + return result +} + +// categorize numbers into even and odd +func CategorizeNumbers(nums []int) map[string][]int { + result := map[string][]int{"even": {}, "odd": {}} + for _, n := range nums { + if n%2 == 0 { + result["even"] = append(result["even"], n) + } else { + result["odd"] = append(result["odd"], n) + } + } + return result +} + +// sort the slices in ascending order +func SortNumbers(result map[string][]int) { + sort.Ints(result["even"]) + sort.Ints(result["odd"]) +} diff --git a/pkg/learn-go/3001302/numbers/numbers.json b/pkg/learn-go/3001302/numbers/numbers.json new file mode 100644 index 0000000..7439d15 --- /dev/null +++ b/pkg/learn-go/3001302/numbers/numbers.json @@ -0,0 +1,11 @@ +[ + 1, + 5, + 6, + 9, + 10, + 13, + 50, + 53, + 99 +] \ No newline at end of file diff --git a/pkg/learn-go/3001302/numbers/numbers_test.go b/pkg/learn-go/3001302/numbers/numbers_test.go new file mode 100644 index 0000000..412f795 --- /dev/null +++ b/pkg/learn-go/3001302/numbers/numbers_test.go @@ -0,0 +1,110 @@ +// numbers_test.go +package numbers + +import ( + "reflect" + "testing" +) + +type FormatSliceTestCase struct { + input []int + expected string +} + +var formatSliceTestCases = []FormatSliceTestCase{ + { + input: []int{}, + expected: "", + }, + { + input: []int{1}, + expected: "1", + }, + { + input: []int{1, 2, 3}, + expected: "1, 2, 3", + }, +} + +func TestFormatSlice(t *testing.T) { + for _, testCase := range formatSliceTestCases { + result := FormatSlice(testCase.input) + + if result != testCase.expected { + t.Errorf("FormatSlice(%v) returned %q, but %q was expected", + testCase.input, + result, + testCase.expected) + } + } +} + +type CategorizeNumbersTestCase struct { + input []int + expected map[string][]int +} + +var categorizeNumbersTestCases = []CategorizeNumbersTestCase{ + { + input: []int{}, + expected: map[string][]int{"even": {}, "odd": {}}, + }, + { + input: []int{2, 4, 6}, + expected: map[string][]int{"even": {2, 4, 6}, "odd": {}}, + }, + { + input: []int{1, 3, 5}, + expected: map[string][]int{"even": {}, "odd": {1, 3, 5}}, + }, + { + input: []int{1, 2, 3, 4, 5, 6}, + expected: map[string][]int{"even": {2, 4, 6}, "odd": {1, 3, 5}}, + }, +} + +func TestCategorizeNumbers(t *testing.T) { + for _, testCase := range categorizeNumbersTestCases { + result := CategorizeNumbers(testCase.input) + + if !reflect.DeepEqual(result, testCase.expected) { + t.Errorf("CategorizeNumbers(%v) returned %+v, but %+v was expected", + testCase.input, + result, + testCase.expected) + } + } +} + +type SortNumbersTestCase struct { + input map[string][]int + expected map[string][]int +} + +var sortNumbersTestCases = []SortNumbersTestCase{ + { + input: map[string][]int{"even": {}, "odd": {}}, + expected: map[string][]int{"even": {}, "odd": {}}, + }, + { + input: map[string][]int{"even": {4, 2, 6}, "odd": {3, 1, 5}}, + expected: map[string][]int{"even": {2, 4, 6}, "odd": {1, 3, 5}}, + }, + { + input: map[string][]int{"even": {2, 4, 6}, "odd": {1, 3, 5}}, + expected: map[string][]int{"even": {2, 4, 6}, "odd": {1, 3, 5}}, + }, +} + +func TestSortNumbers(t *testing.T) { + for _, testCase := range sortNumbersTestCases { + SortNumbers(testCase.input) + + if !reflect.DeepEqual(testCase.input, testCase.expected) { + t.Errorf("SortNumbers(%+v) returned %+v, but %+v was expected", + testCase.input, + testCase.input, + testCase.expected) + } + } +} diff --git a/pkg/learn-go/3001327/go.mod b/pkg/learn-go/3001327/go.mod new file mode 100644 index 0000000..1d2f551 --- /dev/null +++ b/pkg/learn-go/3001327/go.mod @@ -0,0 +1,3 @@ +module 3001327 + +go 1.23.1 \ No newline at end of file diff --git a/pkg/learn-go/3001327/num.go b/pkg/learn-go/3001327/num.go new file mode 100644 index 0000000..db57305 --- /dev/null +++ b/pkg/learn-go/3001327/num.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func Seperator(input string) (map[string][]int, error) { + var numbers []int + err := json.Unmarshal([]byte(input), &numbers) + if err != nil { + return nil, err + } + + numMap := map[string][]int{ + "even": {}, + "odd": {}, + } + + for _, value := range numbers { + if value%2 == 0 { + numMap["even"] = append(numMap["even"], value) + } else { + numMap["odd"] = append(numMap["odd"], value) + } + } + + sort.Ints(numMap["even"]) + sort.Ints(numMap["odd"]) + + fmt.Println(numMap) + return numMap, nil +} + +func main() { + // Define the input slice + input := `[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]` + + // Call the Seperator function + Seperator(input) +} diff --git a/pkg/learn-go/3001327/num_test.go b/pkg/learn-go/3001327/num_test.go new file mode 100644 index 0000000..097fd97 --- /dev/null +++ b/pkg/learn-go/3001327/num_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "reflect" + "testing" +) + +var example = []struct { + input string + expected map[string][]int +}{ + { + input: `[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]`, + expected: map[string][]int{ + "even": {2, 4, 6, 8, 10}, + "odd": {1, 3, 5, 7, 9}, + }, + }, + { + input: `[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]`, + expected: map[string][]int{ + "even": {2, 4, 6, 8, 10}, + "odd": {1, 3, 5, 7, 9}, + }, + }, + { + input: `[ -6, -5, -4, -3, -2, -1, 1, 2, 3, 4]`, + expected: map[string][]int{ + "even": {-6, -4, -2, 2, 4}, + "odd": {-5, -3, -1, 1, 3}, + }, + }, + { + input: `[10, 8, 8, 7, 6, 6, 4, 3, 2, 1]`, + expected: map[string][]int{ + "even": {2, 4, 6, 6, 8, 8, 10}, + "odd": {1, 3, 7}, + }, + }, + { + input: `[10, 8, 6, 4, 2]`, + expected: map[string][]int{ + "even": {2, 4, 6, 8, 10}, + "odd": {}, + }, + }, + { + input: `[9, 7, 5, 3, 1]`, + expected: map[string][]int{ + "even": {}, + "odd": {1, 3, 5, 7, 9}, + }, + }, + { + input: `[]`, + expected: map[string][]int{ + "even": {}, + "odd": {}, + }, + }, +} + +func TestSeperator(t *testing.T) { + for _, singleCase := range example { + result, err := Seperator(singleCase.input) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !reflect.DeepEqual(result, singleCase.expected) { + t.Errorf("For input %v, expected %v but got %v", singleCase.input, singleCase.expected, result) + } + } +} diff --git a/pkg/learn-go/3001838/go.mod b/pkg/learn-go/3001838/go.mod new file mode 100644 index 0000000..4993087 --- /dev/null +++ b/pkg/learn-go/3001838/go.mod @@ -0,0 +1,3 @@ +module 3001838 + +go 1.23.1 diff --git a/pkg/learn-go/3001838/oddeven.go b/pkg/learn-go/3001838/oddeven.go new file mode 100644 index 0000000..7c4c42d --- /dev/null +++ b/pkg/learn-go/3001838/oddeven.go @@ -0,0 +1,35 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func ProcessNumbers(numbers string) (map[string][]int, error) { + + var intArrNrs []int + e := json.Unmarshal([]byte(numbers), &intArrNrs) + if e != nil { + return nil, e + } + + result := map[string][]int{ + "odd": {}, + "even": {}, + } + + for _, num := range intArrNrs { + if num%2 == 0 { + result["even"] = append(result["even"], num) + } else { + result["odd"] = append(result["odd"], num) + } + } + + sort.Ints(result["odd"]) + sort.Ints(result["even"]) + fmt.Println(result) + + return result, nil +} diff --git a/pkg/learn-go/3001838/oddeven_test.go b/pkg/learn-go/3001838/oddeven_test.go new file mode 100644 index 0000000..afd80bf --- /dev/null +++ b/pkg/learn-go/3001838/oddeven_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestProcessNumbers(t *testing.T) { + tests := []struct { + input string + expected map[string][]int + hasError bool + }{ + { + input: `[1, 4, 3, 10, 5, 2, 9, 7, 8, 6]`, + expected: map[string][]int{ + "odd": {1, 3, 5, 7, 9}, + "even": {2, 4, 6, 8, 10}, + }, + hasError: false, + }, + { + input: `[]`, + expected: map[string][]int{ + "odd": {}, + "even": {}, + }, + hasError: false, + }, + { + input: `[1, "two", 3]`, + expected: nil, + hasError: true, + }, + } + + for i, tc := range tests { + t.Logf("Running test case %d with input: %s", i+1, tc.input) + result, err := ProcessNumbers(tc.input) + + if tc.hasError { + if err == nil { + t.Errorf("expected an error for input %v, but got none", tc.input) + } else { + t.Logf("Expected error received for input: %s", tc.input) + } + } else { + if err != nil { + t.Errorf("unexpected error for input %v: %v", tc.input, err) + } else { + t.Logf("Result: %v", result) + } + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("expected %v, got %v", tc.expected, result) + } + } + } +} diff --git a/pkg/learn-go/3002102/go.mod b/pkg/learn-go/3002102/go.mod new file mode 100644 index 0000000..4c43028 --- /dev/null +++ b/pkg/learn-go/3002102/go.mod @@ -0,0 +1,3 @@ +module 3002102 + +go 1.23.1 diff --git a/pkg/learn-go/3002102/solution.go b/pkg/learn-go/3002102/solution.go new file mode 100644 index 0000000..ca65297 --- /dev/null +++ b/pkg/learn-go/3002102/solution.go @@ -0,0 +1,34 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func OddEvenMap(Input string) (map[string][]int, error) { + var numbers []int + err := json.Unmarshal([]byte(Input), &numbers) + if err != nil { + return nil, err + } + + result := map[string][]int{ + "odd": {}, + "even": {}, + } + + for _, num := range numbers { + if num%2 == 0 { + result["even"] = append(result["even"], num) + } else { + result["odd"] = append(result["odd"], num) + } + } + + sort.Ints(result["odd"]) + sort.Ints(result["even"]) + + fmt.Println(result) + return result, nil +} diff --git a/pkg/learn-go/3002102/solution_test.go b/pkg/learn-go/3002102/solution_test.go new file mode 100644 index 0000000..4beec73 --- /dev/null +++ b/pkg/learn-go/3002102/solution_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestOddEvenMapJSON(t *testing.T) { + tests := []struct { + input string + expected map[string][]int + }{ + { + input: `[5, 2, 9, 8, 3, 4, 7, 6, 1]`, + expected: map[string][]int{ + "odd": {1, 3, 5, 7, 9}, + "even": {2, 4, 6, 8}, + }, + }, + { + input: `[10, 15, 20, 25]`, + expected: map[string][]int{ + "odd": {15, 25}, + "even": {10, 20}, + }, + }, + { + input: `[]`, + expected: map[string][]int{ + "odd": {}, + "even": {}, + }, + }, + } + + for _, test := range tests { + result, err := OddEvenMap(test.input) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !reflect.DeepEqual(result, test.expected) { + t.Errorf("For input %v, expected %v but got %v", test.input, test.expected, result) + } + } +} diff --git a/pkg/learn-go/3002869/go.mod b/pkg/learn-go/3002869/go.mod new file mode 100644 index 0000000..9496356 --- /dev/null +++ b/pkg/learn-go/3002869/go.mod @@ -0,0 +1,3 @@ +module numbers + +go 1.23.1 diff --git a/pkg/learn-go/3002869/numbers.go b/pkg/learn-go/3002869/numbers.go new file mode 100644 index 0000000..b3954de --- /dev/null +++ b/pkg/learn-go/3002869/numbers.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" +) + +func makeNumbers(input string) map[string][]int { + jsonInput := input + var inputArray []int + err := json.Unmarshal([]byte(jsonInput), &inputArray) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err) + os.Exit(1) + } + + result := make(map[string][]int) + result["odd"] = []int{} + result["even"] = []int{} + + for _, num := range inputArray { + if num%2 == 0 { + result["even"] = append(result["even"], num) + } else { + result["odd"] = append(result["odd"], num) + } + } + + sort.Ints(result["odd"]) + sort.Ints(result["even"]) + + fmt.Println(result) + return result + +} diff --git a/pkg/learn-go/3002869/numbers_test.go b/pkg/learn-go/3002869/numbers_test.go new file mode 100644 index 0000000..d8e0abb --- /dev/null +++ b/pkg/learn-go/3002869/numbers_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestOddEven(t *testing.T) { + jsonInputTest1 := `[3, 1, 4, 5, 8, 9, 2, 7]` + resultTest1 := makeNumbers(jsonInputTest1) + + expectedOutputTest1 := map[string][]int{ + "odd": {1, 3, 5, 7, 9}, + "even": {2, 4, 8}, + } + if !reflect.DeepEqual(resultTest1, expectedOutputTest1) { + t.Errorf("Numbers not equal") + } + + jsonInputTest2 := `[0 , 0, 1]` + resultTest2 := makeNumbers(jsonInputTest2) + + expectedOutputTest2 := map[string][]int{ + "odd": {1}, + "even": {0, 0}, + } + if !reflect.DeepEqual(resultTest2, expectedOutputTest2) { + t.Errorf("Numbers not equal") + } +} diff --git a/pkg/learn-go/3003561/go.mod b/pkg/learn-go/3003561/go.mod new file mode 100644 index 0000000..182b842 --- /dev/null +++ b/pkg/learn-go/3003561/go.mod @@ -0,0 +1,3 @@ +module 3003561 + +go 1.23.2 \ No newline at end of file diff --git a/pkg/learn-go/3003561/solution.go b/pkg/learn-go/3003561/solution.go new file mode 100644 index 0000000..6fcd3c4 --- /dev/null +++ b/pkg/learn-go/3003561/solution.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" +) + +func JSONtoMap(userInput string) (map[string][]int, error) { + var enteredNumbers []int + err := json.Unmarshal([]byte(userInput), &enteredNumbers) + if err != nil { + return nil, err + } + + finalMap := map[string][]int{ + "odd": {}, + "even": {}, + } + + for _, num := range enteredNumbers { + if num%2 == 0 { + finalMap["even"] = append(finalMap["even"], num) + } else { + finalMap["odd"] = append(finalMap["odd"], num) + } + } + + sort.Ints(finalMap["even"]) + + sort.Ints(finalMap["odd"]) + + fmt.Println(finalMap) + + return finalMap, nil +} diff --git a/pkg/learn-go/3003561/solution_test.go b/pkg/learn-go/3003561/solution_test.go new file mode 100644 index 0000000..428d834 --- /dev/null +++ b/pkg/learn-go/3003561/solution_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestJSONtoMap(t *testing.T) { + tests := []struct { + userInput string + expected map[string][]int + }{ + { + userInput: `[1,2,3,4,5,6,7,8]`, + expected: map[string][]int{ + "odd": {1, 3, 5, 7}, + "even": {2, 4, 6, 8}, + }, + }, + { + userInput: `[312,561,235,-123,551,131,5124]`, + expected: map[string][]int{ + "odd": {-123, 131, 235, 551, 561}, + "even": {312, 5124}, + }, + }, + } + for _, tt := range tests { + + got, err := JSONtoMap(tt.userInput) + if err != nil { + t.Errorf("JSONtoMap() error = %v", err) + return + } + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("JSONtoMap() for UserInput %v, got = %v, want %v", tt.userInput, got, tt.expected) + } + + } +} diff --git a/pkg/learn-go/3011983/data/data.json b/pkg/learn-go/3011983/data/data.json new file mode 100644 index 0000000..76bf1b5 --- /dev/null +++ b/pkg/learn-go/3011983/data/data.json @@ -0,0 +1,3 @@ +{ + "numbers": [1,4,5,2,3,6,7,8,9,10] +} \ No newline at end of file diff --git a/pkg/learn-go/3011983/data/decimal.json b/pkg/learn-go/3011983/data/decimal.json new file mode 100644 index 0000000..342a5b0 --- /dev/null +++ b/pkg/learn-go/3011983/data/decimal.json @@ -0,0 +1,3 @@ +{ + "numbers": [1.45,1,4,5,2,3,6,7,8,9,10] +} \ No newline at end of file diff --git a/pkg/learn-go/3011983/data/double.json b/pkg/learn-go/3011983/data/double.json new file mode 100644 index 0000000..21100d5 --- /dev/null +++ b/pkg/learn-go/3011983/data/double.json @@ -0,0 +1,3 @@ +{ + "numbers": [1,4,5,2,3,3,6,7,8,8,9,10] +} \ No newline at end of file diff --git a/pkg/learn-go/3011983/data/negative.json b/pkg/learn-go/3011983/data/negative.json new file mode 100644 index 0000000..de742f2 --- /dev/null +++ b/pkg/learn-go/3011983/data/negative.json @@ -0,0 +1,3 @@ +{ + "numbers": [1,-4, 4,5,2,3,6,7,8, -7,9,10] +} \ No newline at end of file diff --git a/pkg/learn-go/3011983/data/only_even.json b/pkg/learn-go/3011983/data/only_even.json new file mode 100644 index 0000000..13f8784 --- /dev/null +++ b/pkg/learn-go/3011983/data/only_even.json @@ -0,0 +1,3 @@ +{ + "numbers": [4,2,6,8,10] +} \ No newline at end of file diff --git a/pkg/learn-go/3011983/data/only_odd.json b/pkg/learn-go/3011983/data/only_odd.json new file mode 100644 index 0000000..333356b --- /dev/null +++ b/pkg/learn-go/3011983/data/only_odd.json @@ -0,0 +1,3 @@ +{ + "numbers": [1,5,3,7,9] +} \ No newline at end of file diff --git a/pkg/learn-go/3011983/data/string.json b/pkg/learn-go/3011983/data/string.json new file mode 100644 index 0000000..459d770 --- /dev/null +++ b/pkg/learn-go/3011983/data/string.json @@ -0,0 +1,3 @@ +{ + "numbers": [1,4,5,"test",3,6,7,8,9,10] +} \ No newline at end of file diff --git a/pkg/learn-go/3011983/go.mod b/pkg/learn-go/3011983/go.mod new file mode 100644 index 0000000..495424d --- /dev/null +++ b/pkg/learn-go/3011983/go.mod @@ -0,0 +1,3 @@ +module 3011983 + +go 1.23.1 diff --git a/pkg/learn-go/3011983/main.go b/pkg/learn-go/3011983/main.go new file mode 100644 index 0000000..92ec976 --- /dev/null +++ b/pkg/learn-go/3011983/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sort" +) + +type Numbers struct { + Numbers []int `json:"numbers"` +} + +func main() { + numberMap, err := exec_odd_even_separator("data/data.json") + if err != nil { + log.Fatal(err) + } + PrintOutput(numberMap) +} + +func exec_odd_even_separator(dataPath string) (numberMap map[string][]int, err error) { + // Read Json File into custom Struct + jsonBytes, err := ReadJsonFileToBytes(dataPath) + if err != nil { + return + } + + // Parse the received bytes into a struct + numbers, err := ParseBytesToStruct(jsonBytes) + if err != nil { + return + } + + // Move numbers from Struct into Map with two Slices: Even and Odd + numberMap = NumbersStructToMapWithSlices(numbers) + + // Sort the two Slices + SortNumbersMap(numberMap) + + return +} + +func ReadJsonFileToBytes(filename string) (jsonBytes []byte, err error) { + jsonBytes, err = os.ReadFile(filename) + return +} + +func ParseBytesToStruct(jsonBytes []byte) (numbers Numbers, err error) { + err = json.Unmarshal(jsonBytes, &numbers) + return +} + +func NumbersStructToMapWithSlices(numbers Numbers) (numberMap map[string][]int) { + numberMap = map[string][]int{"even": make([]int, 0), "odd": make([]int, 0)} + + for _, val := range numbers.Numbers { + if val%2 == 0 { + numberMap["even"] = append(numberMap["even"], val) + } else { + numberMap["odd"] = append(numberMap["odd"], val) + } + } + return +} + +func SortNumbersMap(numberMap map[string][]int) { + sort.Ints(numberMap["even"]) + sort.Ints(numberMap["odd"]) +} + +func PrintOutput(numberMap map[string][]int) { + fmt.Printf("Even Numbers:\n%v\n", numberMap["even"]) + fmt.Printf("Odd Numbers:\n%v\n", numberMap["odd"]) +} diff --git a/pkg/learn-go/3011983/main_test.go b/pkg/learn-go/3011983/main_test.go new file mode 100644 index 0000000..16e8378 --- /dev/null +++ b/pkg/learn-go/3011983/main_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "errors" + "reflect" + "testing" +) + +func TestNumbers_ParseBytesToStruct(t *testing.T) { + expectedStruct := Numbers{[]int{1, 5, 3, 4, 2, 7, 6, 10, 8, 9, 11}} + + jsonMockOutput, err := json.Marshal(expectedStruct) + if err != nil { + t.Fatal(err.Error()) + } + + funcOut, err := ParseBytesToStruct(jsonMockOutput) + if err != nil { + t.Fatal(err.Error()) + } + + if !reflect.DeepEqual(expectedStruct, funcOut) { + t.Errorf("Input and Outputdata are not matching: \n%v\n%v", expectedStruct, funcOut) + } +} + +func TestNumbersStructToMapWithSlices(t *testing.T) { + inputStruct := Numbers{[]int{1, 5, 3, 4, 2, 7, 6, 10, 8, 9, 11}} + expectedMap := map[string][]int{"even": {4, 2, 6, 10, 8}, "odd": {1, 5, 3, 7, 9, 11}} + + outputMap := NumbersStructToMapWithSlices(inputStruct) + + if !reflect.DeepEqual(expectedMap, outputMap) { + t.Errorf("Input and Outputdata are not matching: \n%v\n%v", expectedMap, outputMap) + } +} + +func TestSortNumberMap(t *testing.T) { + inputMap := map[string][]int{"even": {4, 2, 6, 10, 8}, "odd": {1, 5, 3, 7, 9, 11}} + expectedMap := map[string][]int{"even": {2, 4, 6, 8, 10}, "odd": {1, 3, 5, 7, 9, 11}} + + SortNumbersMap(inputMap) + + if !reflect.DeepEqual(inputMap, expectedMap) { + t.Errorf("Input and Outputdata are not matching: \n%v\n%v", expectedMap, inputMap) + } +} + +func TestDoubleValues(t *testing.T) { + expectedMap := map[string][]int{"even": {2, 4, 6, 8, 8, 10}, "odd": {1, 3, 3, 5, 7, 9}} + + outputMap, err := exec_odd_even_separator("data/double.json") + + if err != nil { + t.Fatalf("Unexpected Error occured: %s", err) + } + if !reflect.DeepEqual(expectedMap, outputMap) { + t.Errorf("Input and Outputdata are not matching: \n%v\n%v", expectedMap, outputMap) + } +} + +func TestOnlyOdd(t *testing.T) { + expectedMap := map[string][]int{"even": {}, "odd": {1, 3, 5, 7, 9}} + + outputMap, err := exec_odd_even_separator("data/only_odd.json") + + if err != nil { + t.Fatalf("Unexpected Error occured: %s", err) + } + if !reflect.DeepEqual(expectedMap, outputMap) { + t.Errorf("Input and Outputdata are not matching: \n%v\n%v", expectedMap, outputMap) + } +} + +func TestOnlyEven(t *testing.T) { + expectedMap := map[string][]int{"even": {2, 4, 6, 8, 10}, "odd": {}} + + outputMap, err := exec_odd_even_separator("data/only_even.json") + + if err != nil { + t.Fatalf("Unexpected Error occured: %s", err) + } + if !reflect.DeepEqual(expectedMap, outputMap) { + t.Errorf("Input and Outputdata are not matching: \n%v\n%v", expectedMap, outputMap) + } +} +func TestNegative(t *testing.T) { + expectedMap := map[string][]int{"even": {-4, 2, 4, 6, 8, 10}, "odd": {-7, 1, 3, 5, 7, 9}} + + outputMap, err := exec_odd_even_separator("data/negative.json") + + if err != nil { + t.Fatalf("Unexpected Error occured: %s", err) + } + + if !reflect.DeepEqual(expectedMap, outputMap) { + t.Errorf("Input and Outputdata are not matching: \n%v\n%v", expectedMap, outputMap) + } +} + +func TestDecimal(t *testing.T) { + _, err := exec_odd_even_separator("data/decimal.json") + + var expectedErrorType *json.UnmarshalTypeError + if !errors.As(err, &expectedErrorType) { + t.Fatalf("Unexpeced Error occured%T", err) + } +} + +func TestString(t *testing.T) { + _, err := exec_odd_even_separator("data/string.json") + + var expectedErrorType *json.UnmarshalTypeError + if !errors.As(err, &expectedErrorType) { + t.Fatalf("Unexpeced Error occured%T", err) + } +} diff --git a/pkg/learn-go/mustafa/OddEven.go b/pkg/learn-go/mustafa/OddEven.go new file mode 100644 index 0000000..0bf5fbe --- /dev/null +++ b/pkg/learn-go/mustafa/OddEven.go @@ -0,0 +1,35 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" +) + +func main() { + var numbers []int + json.NewDecoder(os.Stdin).Decode(&numbers) + + var evens []int + var odds []int + + for _, num := range numbers { + if num%2 == 0 { + evens = append(evens, num) + } else { + odds = append(odds, num) + } + } + + sort.Ints(evens) + sort.Ints(odds) + + result := map[string][]int{ + "even": evens, + "odd": odds, + } + + output, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(output)) +} diff --git a/pkg/learn-go/mustafa/OddEven_test.go b/pkg/learn-go/mustafa/OddEven_test.go new file mode 100644 index 0000000..28818db --- /dev/null +++ b/pkg/learn-go/mustafa/OddEven_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "reflect" + "sort" + "testing" +) + +func TestOddEvenSorter(t *testing.T) { + + input1 := []int{4, 7, 1, 8, 5, 10, 3, 2} + expected1 := map[string][]int{ + "even": {2, 4, 8, 10}, + "odd": {1, 3, 5, 7}, + } + + input2 := []int{-4, -7, -1, 8, 5, -10, 3, 2} + expected2 := map[string][]int{ + "even": {-10, -4, 2, 8}, + "odd": {-7, -1, 3, 5}, + } + + runTest(t, input1, expected1) + runTest(t, input2, expected2) +} + +func runTest(t *testing.T, input []int, expected map[string][]int) { + var evens []int + var odds []int + for _, num := range input { + if num%2 == 0 { + evens = append(evens, num) + } else { + odds = append(odds, num) + } + } + sort.Ints(evens) + sort.Ints(odds) + + result := map[string][]int{ + "even": evens, + "odd": odds, + } + + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, but got %v", expected, result) + } +} diff --git a/pkg/learn-go/mustafa/go.mod b/pkg/learn-go/mustafa/go.mod new file mode 100644 index 0000000..af85730 --- /dev/null +++ b/pkg/learn-go/mustafa/go.mod @@ -0,0 +1,3 @@ +module OddEven + +go 1.23.1 diff --git a/pkg/learn-go/mustafa/input.json b/pkg/learn-go/mustafa/input.json new file mode 100644 index 0000000..c359140 --- /dev/null +++ b/pkg/learn-go/mustafa/input.json @@ -0,0 +1 @@ +[4, 7, 1, 8, 5, 10, 3, 2] \ No newline at end of file diff --git a/pkg/logging/go.mod b/pkg/logging/go.mod new file mode 100644 index 0000000..5ea3e9d --- /dev/null +++ b/pkg/logging/go.mod @@ -0,0 +1,3 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/logging + +go 1.23.1 diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go new file mode 100644 index 0000000..d5bce7e --- /dev/null +++ b/pkg/logging/logger.go @@ -0,0 +1,15 @@ +package logging + +import ( + "log/slog" + "os" +) + +func Init(application string) { + handler := slog.NewJSONHandler(os.Stdout, nil) + logger := slog.New(handler.WithAttrs([]slog.Attr{ + slog.String("application", application), + })) + + slog.SetDefault(logger) +} diff --git a/services/carbon-intensity-provider/README.md b/services/carbon-intensity-provider/README.md new file mode 100644 index 0000000..e7b3903 --- /dev/null +++ b/services/carbon-intensity-provider/README.md @@ -0,0 +1 @@ +# Carbon Intensity Provider diff --git a/services/carbon-intensity-provider/adapters/handler-http/handler.go b/services/carbon-intensity-provider/adapters/handler-http/handler.go new file mode 100644 index 0000000..1be4d48 --- /dev/null +++ b/services/carbon-intensity-provider/adapters/handler-http/handler.go @@ -0,0 +1,70 @@ +package handler_http + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/carbon-intensity-provider/ports" +) + +type Handler struct { + service ports.Api + rtr *mux.Router +} + +// ServeHTTP implements http.Handler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) +} + +// NewHandler initializes a new Handler with the given service and sets up routes. +func NewHandler(service ports.Api) *Handler { + h := &Handler{ + service: service, + rtr: mux.NewRouter(), + } + + h.rtr.HandleFunc("/emissions", h.GetEmissionsHandler).Methods("GET") + + return h +} + +func (h *Handler) GetEmissionsHandler(w http.ResponseWriter, r *http.Request) { + latStr := r.URL.Query().Get("latitude") + lonStr := r.URL.Query().Get("longitude") + + if latStr == "" || lonStr == "" { + http.Error(w, "Latitude and Longitude required", http.StatusBadRequest) + return + } + + latitude, err := strconv.ParseFloat(latStr, 64) + if err != nil { + http.Error(w, "Invalid latitude parameter", http.StatusBadRequest) + return + } + + longitude, err := strconv.ParseFloat(lonStr, 64) + if err != nil { + http.Error(w, "Invalid longitude parameter", http.StatusBadRequest) + return + } + + // Get emission data from the service + emissionData, err := h.service.GetEmissions(latitude, longitude, r.Context()) + if err != nil { + http.Error(w, "Failed to get emissions data", http.StatusInternalServerError) + return + } + + // Set the Content-Type header to application/json + w.Header().Set("Content-Type", "application/json") + + // Return JSON response to the client + if err := json.NewEncoder(w).Encode(emissionData); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} diff --git a/services/carbon-intensity-provider/adapters/repo-in-memory/repo.go b/services/carbon-intensity-provider/adapters/repo-in-memory/repo.go new file mode 100644 index 0000000..689db96 --- /dev/null +++ b/services/carbon-intensity-provider/adapters/repo-in-memory/repo.go @@ -0,0 +1,32 @@ +package repo_in_memory + +import ( + "context" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/carbon-intensity-provider/ports" +) + +type Repo struct { + cips map[string]ports.Cip +} + +var _ ports.Repo = (*Repo)(nil) + +func NewRepo() *Repo { + return &Repo{ + cips: make(map[string]ports.Cip), + } +} + +func (r *Repo) Store(cip ports.Cip, ctx context.Context) error { + // r.cips[cip.Id] = cip + return nil +} + +func (r *Repo) FindById(id string, ctx context.Context) (ports.Cip, error) { + cip, ok := r.cips[id] + if !ok { + return ports.Cip{}, ports.ErrCipNotFound + } + return cip, nil +} diff --git a/services/carbon-intensity-provider/core/carbon-intensity-provider.go b/services/carbon-intensity-provider/core/carbon-intensity-provider.go new file mode 100644 index 0000000..e62415c --- /dev/null +++ b/services/carbon-intensity-provider/core/carbon-intensity-provider.go @@ -0,0 +1,73 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/carbon-intensity-provider/ports" +) + +type CipService struct { + repo ports.Repo + notifier ports.Notifier + httpClient *http.Client +} + +// NewCipService creates a new instance of CipService +func NewCipService(repo ports.Repo, notifier ports.Notifier, client *http.Client) *CipService { + // Use the provided client if given; otherwise, use the default client + if client == nil { + client = http.DefaultClient + } + return &CipService{ + repo: repo, + notifier: notifier, + httpClient: client, + } +} + +// GetEmissions implements ports.Api +func (s *CipService) GetEmissions(latitude float64, longitude float64, ctx context.Context) (ports.Cip, error) { + apiKey := "xRwGVonLFlO66" + + url := fmt.Sprintf("https://api.electricitymap.org/v3/carbon-intensity/latest?lat=%f&lon=%f", latitude, longitude) + + // HTTP request to the API + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + log.Fatalf("Fehler beim Erstellen der Anfrage: %v", err) + } + + // Add API key + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + + // Use the service's httpClient to make the request + resp, err := s.httpClient.Do(req) + if err != nil { + log.Fatalf("Error sending the request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Fatalf("Incorrect response from the API: %v", resp.Status) + } + + var result ports.Cip + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + log.Fatalf("Error decoding the response: %v", err) + } + + // Output carbon intensity + fmt.Printf("The carbon intensity at the position (%f, %f) is: %.2f gCO₂/kWh\n", latitude, longitude, result.CarbonIntensity) + fmt.Printf(result.UpdatedAt) + + return ports.Cip{ + CarbonIntensity: result.CarbonIntensity, + UpdatedAt: result.UpdatedAt, + }, err +} + +var _ ports.Api = (*CipService)(nil) diff --git a/services/carbon-intensity-provider/core/carbon-intensity-provider_test.go b/services/carbon-intensity-provider/core/carbon-intensity-provider_test.go new file mode 100644 index 0000000..fd11cbd --- /dev/null +++ b/services/carbon-intensity-provider/core/carbon-intensity-provider_test.go @@ -0,0 +1,61 @@ +package core + +import ( + "context" + "io/ioutil" + "net/http" + "strings" + "testing" +) + +// MockTransport is there to mock HTTP responses +type MockTransport struct { + Response *http.Response + Err error +} + +// RoundTrip executes a single HTTP transaction and returns the mocked response +func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return m.Response, m.Err +} +func TestGetEmissions(t *testing.T) { + // Mock response + mockResponse := `{"carbonIntensity": 150.0, "updatedAt": "2024-11-15T10:00:00Z"}` + + // Create a mock HTTP response with the expected structure + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(mockResponse)), + Header: make(http.Header), + } + + // Create a mock HTTP client using the mock transport + mockClient := &http.Client{ + Transport: &MockTransport{ + Response: resp, + }, + } + + // Create a CipService instance with the mocked http.Client + service := NewCipService(nil, nil, mockClient) + + // Call the GetEmissions function with mock data + latitude, longitude := 52.52, 13.405 // Example coordinates for Berlin + result, err := service.GetEmissions(latitude, longitude, context.Background()) + + // Check for errors + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify the returned result + expectedCarbonIntensity := 150.0 + if result.CarbonIntensity != expectedCarbonIntensity { + t.Errorf("Expected carbon intensity %v, got %v", expectedCarbonIntensity, result.CarbonIntensity) + } + + expectedUpdatedAt := "2024-11-15T10:00:00Z" + if result.UpdatedAt != expectedUpdatedAt { + t.Errorf("Expected updatedAt %v, got %v", expectedUpdatedAt, result.UpdatedAt) + } +} diff --git a/services/carbon-intensity-provider/doc/api.yaml b/services/carbon-intensity-provider/doc/api.yaml new file mode 100644 index 0000000..7743bde --- /dev/null +++ b/services/carbon-intensity-provider/doc/api.yaml @@ -0,0 +1,126 @@ +openapi: 3.0.0 +info: + title: Carbon Intesity Provider API + version: 1.1.0 + description: API for retrieving carbon intensity data from Electricity Maps and providing it to the Job Scheduler in the GCLS platform, based on geographic coordinates. + +paths: + /emissions: + get: + summary: Get Carbon Intensity by Coordinates + description: Retrieve the latest carbon intensity data for a specific location using latitude and longitude. + parameters: + - name: latitude + in: query + required: true + description: Latitude of the location (in decimal degrees). + schema: + type: number + format: float + example: 37.7749 + - name: longitude + in: query + required: true + description: Longitude of the location (in decimal degrees). + schema: + type: number + format: float + example: -122.4194 + - name: id + in: query + required: false + description: Unique identifier for the request or session (optional). + schema: + type: integer + example: 12345 + responses: + '200': + description: Successfully retrieved carbon intensity data. + content: + application/json: + schema: + type: object + properties: + location: + $ref: '#/components/schemas/Location' + id: + type: integer + description: Unique identifier for this data request. + example: 12345 + carbonIntensity: + type: number + description: Current carbon intensity in gCO2eq/kWh. + example: 150.5 + updatedAt: + type: string + format: date-time + description: Timestamp of the last update from Electricity Maps. + example: "2024-10-29T12:00:00Z" + '400': + description: Invalid request parameters. + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: Error message explaining the issue. + example: "Invalid latitude or longitude" + '404': + description: Carbon intensity data not found for the specified location. + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: Error message explaining the issue. + example: "Carbon intensity data not available for the specified location." + '500': + description: Internal server error. + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: Error message explaining the issue. + example: "Failed to retrieve data from Electricity Maps." + +components: + schemas: + Location: + type: object + properties: + latitude: + type: number + format: float + description: Latitude of the requested location. + example: 37.7749 + longitude: + type: number + format: float + description: Longitude of the requested location. + example: -122.4194 + + CarbonIntensityData: + type: object + properties: + location: + $ref: '#/components/schemas/Location' + id: + type: integer + description: Unique identifier for this data request. + example: 12345 + carbonIntensity: + type: number + description: Current carbon intensity in gCO2eq/kWh. + example: 150.5 + updatedAt: + type: string + format: date-time + description: Timestamp of the last update from Electricity Maps. + example: "2024-10-29T12:00:00Z" \ No newline at end of file diff --git a/services/carbon-intensity-provider/go.mod b/services/carbon-intensity-provider/go.mod new file mode 100644 index 0000000..a246a91 --- /dev/null +++ b/services/carbon-intensity-provider/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/carbon-intensity-provider + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 diff --git a/services/carbon-intensity-provider/go.sum b/services/carbon-intensity-provider/go.sum new file mode 100644 index 0000000..7128337 --- /dev/null +++ b/services/carbon-intensity-provider/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/carbon-intensity-provider/main.go b/services/carbon-intensity-provider/main.go new file mode 100644 index 0000000..ac69812 --- /dev/null +++ b/services/carbon-intensity-provider/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + handler_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/carbon-intensity-provider/adapters/handler-http" + repo "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/carbon-intensity-provider/adapters/repo-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/carbon-intensity-provider/core" +) + +func main() { + + // Initialize the http client + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + core := core.NewCipService(repo.NewRepo(), nil, httpClient) + + srv := &http.Server{Addr: ":8080"} + + h := handler_http.NewHandler(core) + http.Handle("/", h) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + log.Print("listening...") + srv.ListenAndServe() + log.Print("Done") +} diff --git a/services/carbon-intensity-provider/ports/api.go b/services/carbon-intensity-provider/ports/api.go new file mode 100644 index 0000000..19c7625 --- /dev/null +++ b/services/carbon-intensity-provider/ports/api.go @@ -0,0 +1,12 @@ +package ports + +import ( + "context" + "errors" +) + +var ErrCipNotFound = errors.New("cip not found") + +type Api interface { + GetEmissions(latitude float64, longitude float64, ctx context.Context) (Cip, error) +} diff --git a/services/carbon-intensity-provider/ports/model.go b/services/carbon-intensity-provider/ports/model.go new file mode 100644 index 0000000..3a860b8 --- /dev/null +++ b/services/carbon-intensity-provider/ports/model.go @@ -0,0 +1,6 @@ +package ports + +type Cip struct { + CarbonIntensity float64 `json:"carbonIntensity"` + UpdatedAt string `json:"updatedAt"` +} diff --git a/services/carbon-intensity-provider/ports/notifier.go b/services/carbon-intensity-provider/ports/notifier.go new file mode 100644 index 0000000..b0af18c --- /dev/null +++ b/services/carbon-intensity-provider/ports/notifier.go @@ -0,0 +1,9 @@ +package ports + +import ( + "context" +) + +type Notifier interface { + CipChanged(cip Cip, ctx context.Context) +} diff --git a/services/carbon-intensity-provider/ports/repo.go b/services/carbon-intensity-provider/ports/repo.go new file mode 100644 index 0000000..932d378 --- /dev/null +++ b/services/carbon-intensity-provider/ports/repo.go @@ -0,0 +1,10 @@ +package ports + +import ( + "context" +) + +type Repo interface { + Store(cip Cip, ctx context.Context) error + FindById(id string, ctx context.Context) (Cip, error) +} diff --git a/services/consumer-gateway/Dockerfile b/services/consumer-gateway/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/services/consumer-gateway/Makefile b/services/consumer-gateway/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/services/consumer-gateway/README.md b/services/consumer-gateway/README.md new file mode 100644 index 0000000..d1a6d5c --- /dev/null +++ b/services/consumer-gateway/README.md @@ -0,0 +1,3 @@ +# Consumer Gateway Service + +The Consumer Gateway Service is a service that provides a REST API for consumers to interact with the system. It is the entry point for all consumer requests. diff --git a/services/consumer-gateway/adapters/handler-http/handler.go b/services/consumer-gateway/adapters/handler-http/handler.go new file mode 100644 index 0000000..c5f4797 --- /dev/null +++ b/services/consumer-gateway/adapters/handler-http/handler.go @@ -0,0 +1,90 @@ +package handler_http + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/ports" +) + +type ConsumerGatewayHandler struct { + service ports.Api + rtr mux.Router +} + +func NewHandler(service ports.Api) *ConsumerGatewayHandler { + handler := &ConsumerGatewayHandler{ + service: service, + rtr: *mux.NewRouter(), + } + + handler.rtr.HandleFunc("/jobs/{jobId}", handler.handleGetJob).Methods("GET") + handler.rtr.HandleFunc("/jobs", handler.handleGetJobs).Methods("GET") + handler.rtr.HandleFunc("/jobs", handler.handleCreateJob).Methods("POST") + handler.rtr.HandleFunc("/login", handler.handleLogin).Methods("POST") + + return handler +} + +func (h *ConsumerGatewayHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) +} + +func (h *ConsumerGatewayHandler) handleGetJob(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + job, err := h.service.GetJob(vars["jobId"], r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(job) +} + +func (h *ConsumerGatewayHandler) handleGetJobs(w http.ResponseWriter, r *http.Request) { + jobs, err := h.service.GetJobs(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jobs) +} + +func (h *ConsumerGatewayHandler) handleCreateJob(w http.ResponseWriter, r *http.Request) { + var createJobDto ports.CreateJobDto + err := json.NewDecoder(r.Body).Decode(&createJobDto) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = h.service.CreateJob(createJobDto, r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *ConsumerGatewayHandler) handleLogin(w http.ResponseWriter, r *http.Request) { + var authRequestDto ports.AuthRequestDto + err := json.NewDecoder(r.Body).Decode(&authRequestDto) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + authResponseDto, err := h.service.Login(authRequestDto, r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(authResponseDto) +} diff --git a/services/consumer-gateway/adapters/rest-client-in-memory/rest-client.go b/services/consumer-gateway/adapters/rest-client-in-memory/rest-client.go new file mode 100644 index 0000000..1a82234 --- /dev/null +++ b/services/consumer-gateway/adapters/rest-client-in-memory/rest-client.go @@ -0,0 +1,72 @@ +package rest_client_in_memory + +import ( + "context" + "strconv" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/ports" +) + +type Store struct { + jobs map[string]ports.JobDto + users map[string]string + idCounter int +} + +var _ ports.RestClient = (*Store)(nil) // Check if the Store struct implements the RestClient interface + +func NewRestClient(users map[string]string) *Store { + return &Store{ + jobs: make(map[string]ports.JobDto), + users: users, + } +} + +func (s *Store) GetJob(id string, ctx context.Context) (ports.JobDto, error) { + job, ok := s.jobs[id] + if !ok { + return ports.JobDto{}, ports.ErrJobNotFound + } + return job, nil +} + +func (s *Store) GetJobs(ctx context.Context) ([]ports.JobDto, error) { + var jobs []ports.JobDto + + for _, job := range s.jobs { + jobs = append(jobs, job) + } + + return jobs, nil +} + +func (s *Store) CreateJob(createJobDto ports.CreateJobDto, ctx context.Context) error { + if createJobDto.Job.Name == "" || createJobDto.Job.ImageName == "" { + return ports.ErrJobNotCreated + } + + s.idCounter++ + + job := ports.JobDto{ + Id: strconv.Itoa(s.idCounter), + Name: createJobDto.Job.Name, + ImageName: createJobDto.Job.ImageName, + EnvironmentVariables: createJobDto.Job.EnvironmentVariables, + ConsumerLongitude: createJobDto.Job.ConsumerLongitude, + ConsumerLatitude: createJobDto.Job.ConsumerLatitude, + } + + s.jobs[job.Id] = job + return nil +} + +func (s *Store) Login(authRequestDto ports.AuthRequestDto, ctx context.Context) (ports.AuthResponseDto, error) { + userPassword, ok := s.users[authRequestDto.Credentials.Username] + if !ok { + return ports.AuthResponseDto{}, ports.ErrLoginFailed + } + if userPassword != authRequestDto.Credentials.Password { + return ports.AuthResponseDto{}, ports.ErrLoginFailed + } + return ports.AuthResponseDto{Token: "abc"}, nil +} diff --git a/services/consumer-gateway/core/consumer_service.go b/services/consumer-gateway/core/consumer_service.go new file mode 100644 index 0000000..2ed89fa --- /dev/null +++ b/services/consumer-gateway/core/consumer_service.go @@ -0,0 +1,63 @@ +package core + +import ( + "context" + "reflect" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/ports" +) + +type ConsumerService struct { + restClient ports.RestClient +} + +var _ ports.Api = (*ConsumerService)(nil) + +func NewConsumerService(restClient ports.RestClient) *ConsumerService { + return &ConsumerService{restClient: restClient} +} + +func (s *ConsumerService) GetJob(id string, ctx context.Context) (ports.JobResponseDto, error) { + job, err := s.restClient.GetJob(id, ctx) + + jobResponse := ports.JobResponseDto{} + getJobResponseDto(&job, &jobResponse) + + return jobResponse, err +} + +func (s *ConsumerService) GetJobs(ctx context.Context) ([]ports.JobResponseDto, error) { + jobs, err := s.restClient.GetJobs(ctx) + + var jobResponses []ports.JobResponseDto + + for _, job := range jobs { + jobResponse := ports.JobResponseDto{} + getJobResponseDto(&job, &jobResponse) + jobResponses = append(jobResponses, jobResponse) + } + + return jobResponses, err +} + +func (s *ConsumerService) CreateJob(createJobDto ports.CreateJobDto, ctx context.Context) error { + return s.restClient.CreateJob(createJobDto, ctx) +} + +func (s *ConsumerService) Login(loginDto ports.AuthRequestDto, ctx context.Context) (ports.AuthResponseDto, error) { + return s.restClient.Login(loginDto, ctx) +} + +func getJobResponseDto(job *ports.JobDto, jobResponse *ports.JobResponseDto) { + jobValue := reflect.ValueOf(job).Elem() + jobResponseValue := reflect.ValueOf(jobResponse).Elem() + + for i := 0; i < jobValue.NumField(); i++ { + jobField := jobValue.Type().Field(i) + jobResponseField := jobResponseValue.FieldByName(jobField.Name) + + if jobResponseField.IsValid() && jobResponseField.CanSet() { + jobResponseField.Set(jobValue.Field(i)) + } + } +} diff --git a/services/consumer-gateway/core/consumer_service_test.go b/services/consumer-gateway/core/consumer_service_test.go new file mode 100644 index 0000000..cb23971 --- /dev/null +++ b/services/consumer-gateway/core/consumer_service_test.go @@ -0,0 +1,458 @@ +package core + +import ( + "context" + "reflect" + "testing" + + rest_client_in_memory "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/adapters/rest-client-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/ports" +) + +// Test GetJobs -> given: zero, then no jobs returned +// Test GetJobs --> given: one, then one job returned +// Test GetJobs --> given: two, then two jobs returned + +func TestGetJobs(t *testing.T) { + type response struct { + responseDto []ports.JobResponseDto + err error + } + + tests := []struct { + name string + givenJobs []ports.CreateJobDto + res response + }{ + { + name: "Zero jobs are stored and none are returned", + givenJobs: []ports.CreateJobDto{}, + res: response{ + responseDto: []ports.JobResponseDto{}, + err: nil, + }, + }, + { + name: "One job is stored and one is returned", + givenJobs: []ports.CreateJobDto{ + { + RequestId: "a", + Job: ports.BaseJob{ + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + }, + res: response{ + responseDto: []ports.JobResponseDto{ + { + Id: "1", + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerId: "", + Status: "", + StandardOutput: "", + CreatedAt: 0, + StartedAt: 0, + FinishedAt: 0, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + Co2EquivalentEmissionConsumer: 0.0, + EstimatedCo2Equivalent: 0.0, + }, + }, + err: nil, + }, + }, + { + name: "Two jobs are stored and two are returned", + givenJobs: []ports.CreateJobDto{ + { + RequestId: "a", + Job: ports.BaseJob{ + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + { + RequestId: "a", + Job: ports.BaseJob{ + Name: "Job2", + ImageName: "Image2", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + }, + res: response{ + responseDto: []ports.JobResponseDto{ + { + Id: "1", + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerId: "", + Status: "", + StandardOutput: "", + CreatedAt: 0, + StartedAt: 0, + FinishedAt: 0, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + Co2EquivalentEmissionConsumer: 0.0, + EstimatedCo2Equivalent: 0.0, + }, + { + Id: "2", + Name: "Job2", + ImageName: "Image2", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerId: "", + Status: "", + StandardOutput: "", + CreatedAt: 0, + StartedAt: 0, + FinishedAt: 0, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + Co2EquivalentEmissionConsumer: 0.0, + EstimatedCo2Equivalent: 0.0, + }, + }, + err: nil, + }, + }, + } + + for _, tt := range tests { + restClient := rest_client_in_memory.NewRestClient(make(map[string]string)) + consumerService := *NewConsumerService(restClient) + + t.Run(tt.name, func(t *testing.T) { + var ctx context.Context + // GIVEN + for _, dto := range tt.givenJobs { + consumerService.CreateJob(dto, ctx) + } + + // WHEN + jobs, err := consumerService.GetJobs(ctx) + + // THEN + for i, v := range tt.res.responseDto { + if !reflect.DeepEqual(v, jobs[i]) { + t.Errorf("Incorrect Response. Expected '%v' but got '%v'.", tt.res.responseDto, jobs) + } + } + + if err != tt.res.err { + t.Error("Unexpected Error was thrown.") + } + }) + } +} + +// Test GetJob -> given: zero, then no job returned +// Test GetJob -> given: one, when job with that id requested, then return one job +// Test GetJob -> given: one, when job with other id requested, then return no job +func TestGetJob(t *testing.T) { + type response struct { + responseDto ports.JobResponseDto + err error + } + + tests := []struct { + name string + givenJobs []ports.CreateJobDto + requestedId string + res response + }{ + { + name: "Zero jobs are stored and none is returned", + givenJobs: []ports.CreateJobDto{}, + requestedId: "a", + res: response{ + responseDto: ports.JobResponseDto{}, + err: ports.ErrJobNotFound, + }, + }, + { + name: "One job is stored and job with correct id is requested and returned", + givenJobs: []ports.CreateJobDto{ + { + RequestId: "a", + Job: ports.BaseJob{ + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + }, + requestedId: "1", + res: response{ + responseDto: ports.JobResponseDto{ + Id: "1", + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerId: "", + Status: "", + StandardOutput: "", + CreatedAt: 0, + StartedAt: 0, + FinishedAt: 0, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + Co2EquivalentEmissionConsumer: 0.0, + EstimatedCo2Equivalent: 0.0, + }, + err: nil, + }, + }, + { + name: "One job is stored and job with incorrect id is requested and no job and error returned", + givenJobs: []ports.CreateJobDto{ + { + RequestId: "a", + Job: ports.BaseJob{ + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + }, + requestedId: "a", + res: response{ + responseDto: ports.JobResponseDto{}, + err: ports.ErrJobNotFound, + }, + }, + } + + for _, tt := range tests { + restClient := rest_client_in_memory.NewRestClient(make(map[string]string)) + consumerService := *NewConsumerService(restClient) + + t.Run(tt.name, func(t *testing.T) { + var ctx context.Context + + // GIVEN + for _, dto := range tt.givenJobs { + consumerService.CreateJob(dto, ctx) + } + + // WHEN + job, err := consumerService.GetJob(tt.requestedId, ctx) + + // THEN + if reflect.DeepEqual(job, ports.JobResponseDto{}) && err == nil { + t.Error("Error was not thrown") + } + + if !reflect.DeepEqual(job, tt.res.responseDto) { + t.Errorf("Incorrect response. Expected %v, but got %v.", tt.res.responseDto, job) + } + }) + } +} + +// Test CreateJob -> when creating job, then error with nil is returned +// Test CreateJob -> when creating job with empty job name and image name, error is returned +// Test CreateJob -> when creating job with empty job name, error is returned +// Test CreateJob -> when creating job with empty image name, error is returned +func TestCreateJob(t *testing.T) { + tests := []struct{ + name string + jobData ports.CreateJobDto + res error + }{ + { + name: "Create Job correctely and returned error is nil", + jobData: ports.CreateJobDto{ + RequestId: "a", + Job: ports.BaseJob{ + Name: "Job1", + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + res: nil, + }, + { + name: "Create Job without job name and image name and error is returned", + jobData: ports.CreateJobDto{ + RequestId: "a", + Job: ports.BaseJob{ + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + res: ports.ErrJobNotCreated, + }, + { + name: "Create Job without job name and error is returned", + jobData: ports.CreateJobDto{ + RequestId: "a", + Job: ports.BaseJob{ + ImageName: "Image1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + res: ports.ErrJobNotCreated, + }, + { + name: "Create Job without image name and error is returned", + jobData: ports.CreateJobDto{ + RequestId: "a", + Job: ports.BaseJob{ + Name: "Job1", + EnvironmentVariables: map[string]string{ + "env1": "env1", + }, + ConsumerLongitude: 1.234, + ConsumerLatitude: 145.45, + }, + }, + res: ports.ErrJobNotCreated, + }, + } + + for _, tt := range tests { + restClient := rest_client_in_memory.NewRestClient(make(map[string]string)) + consumerService := *NewConsumerService(restClient) + + t.Run(tt.name, func(t *testing.T) { + var ctx context.Context + + // WHEN + possibleError := consumerService.CreateJob(tt.jobData, ctx) + + // THEN + if possibleError != tt.res { + t.Error("Unexpected error or expected error was not thrown.") + } + }) + } +} + +// Test Login -> given: one user, when login with correct user, then return token "abc" and err nil +// Test Login -> given: one user, when login with incorrect user, then return token empty, and error +func TestLogin(t *testing.T) { + restClient := rest_client_in_memory.NewRestClient(map[string]string{ + "user1": "password1", + }) + consumerService := *NewConsumerService(restClient) + + type response struct { + responseDto ports.AuthResponseDto + err error + } + + tests := []struct { + name string + requestDto ports.AuthRequestDto + res response + }{ + { + name: "Login with correct user and token is returned", + requestDto: ports.AuthRequestDto{ + RequestId: "a", + Credentials: ports.Credentials{ + Username: "user1", + Password: "password1", + }, + }, + res: response{ + responseDto: ports.AuthResponseDto{Token: "abc"}, + err: nil, + }, + }, + { + name: "Login with correct username but incorred password and token is not returned", + requestDto: ports.AuthRequestDto{ + RequestId: "a", + Credentials: ports.Credentials{ + Username: "user1", + Password: "password2", + }, + }, + res: response{ + responseDto: ports.AuthResponseDto{}, + err: ports.ErrLoginFailed, + }, + }, + { + name: "Login with incorrect username and token is not returned", + requestDto: ports.AuthRequestDto{ + RequestId: "a", + Credentials: ports.Credentials{ + Username: "user2", + Password: "password2", + }, + }, + res: response{ + responseDto: ports.AuthResponseDto{}, + err: ports.ErrLoginFailed, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ctx context.Context + + // WHEN + responseDto, err := consumerService.Login(tt.requestDto, ctx) + + // THEN + if reflect.DeepEqual(responseDto, ports.AuthResponseDto{}) && err == nil { + t.Error("Expected Error was not thrown") + } + + if !reflect.DeepEqual(responseDto, tt.res.responseDto) { + t.Errorf("Incorrect Response. Expected %v, Received %v", tt.res.responseDto, responseDto) + } + }) + } +} diff --git a/services/consumer-gateway/doc/api.yaml b/services/consumer-gateway/doc/api.yaml new file mode 100644 index 0000000..1a14f44 --- /dev/null +++ b/services/consumer-gateway/doc/api.yaml @@ -0,0 +1,242 @@ +openapi: 3.0.0 +info: + title: Consumer Gateway + description: REST API documentation for the Consumer Gateway Service. The Consumer Gateway Service is responsible for handling incoming requests from the consumer client and forwarding them to the appropriate service. + version: 0.1.0 +security: + - bearerAuth: [] +tags: + - name: job + - name: login +paths: + /jobs: + get: + tags: + - job + summary: Get all compute jobs that belong to the current user. + description: Get all compute jobs that belong to the current user. + responses: + '200': + description: Compute Jobs that belong to the user. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/job' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '500': + $ref: '#/components/responses/InternalServerError' + security: + - bearerAuth: [ ] + post: + tags: + - job + summary: Create a new compute job. + description: Create a new compute job for the Job Scheduler. The request id is an idempotency key generated by the client and must be a unique identifier for every request. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + requestId: + type: string + description: The unique identifier of the request generated by the client. + example: dad91cf8-6011-4155-81d3-6c4ce64abb6a + job: + type: object + properties: + name: + $ref: '#/components/schemas/jobName' + imageName: + $ref: '#/components/schemas/imageName' + environmentVariables: + $ref: '#/components/schemas/environmentVariables' + consumerLongitude: + $ref: '#/components/schemas/consumerLongitude' + consumerLatitude: + $ref: '#/components/schemas/consumerLatitude' + responses: + '201': + description: Success - Created a new compute job. + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '422': + $ref: '#/components/responses/UnprocessableEntityError' + '500': + $ref: '#/components/responses/InternalServerError' + security: + - bearerAuth: [ ] + /jobs/{jobId}: + get: + tags: + - job + summary: Get specific compute job. + description: Returns specific compute job with the jobId in the URL paramter that belongs to the user. + parameters: + - name: jobId + in: path + description: ID of compute job to return. + required: true + schema: + type: string + responses: + '200': + description: Compute job with specified jobId that belongs to the user. + content: + application/json: + schema: + $ref: '#/components/schemas/job' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + description: Not Found - Job cannot be found. + '500': + $ref: '#/components/responses/InternalServerError' + /login: + post: + tags: + - login + summary: Authenticates a user and returns a JWT + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + requestId: + type: string + description: The unique identifier of the request generated by the client. + example: dad91cf8-6011-4155-81d3-6c4ce64abb6a + credentials: + type: object + properties: + username: + type: string + password: + type: string + responses: + '200': + description: JWT token generated + content: + application/json: + schema: + type: object + properties: + token: + type: string #Tatsächlich werden JWT als encrypted String übertragen? + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + environmentVariables: + type: object + properties: + env1: + type: string + description: Environment variable 1. + example: value + env2: + type: string + description: Environment variable 2. + example: value + imageName: + type: string + description: The name of the DockerHub image. + example: DockerHubImageName + consumerLongitude: + type: number + format: double + description: Longitude of the location of the consumer that created the compute job. + example: 37.278424 + consumerLatitude: + type: number + format: double + description: Latitude of the location of the consumer that created the compute job. + example: 247.695617 + job: + type: object + properties: + id: + type: string + description: Unique ID of the compute job. + example: eb12c557-cbc8-45be-b7ec-9b936730171e + name: + $ref: '#/components/schemas/jobName' + consumerId: + type: string + description: Id of the creator of this job. It is defined at job creation. + example: d2d171f4-f99f-4576-816c-4e58c004585b + imageName: + $ref: '#/components/schemas/imageName' + environmentVariables: + $ref: '#/components/schemas/environmentVariables' + status: + type: string # or define the enum here as well? + description: Current status of the compute job. + example: RUNNING + standardOutput: + type: string + description: Output of the compute job after completion. + example: An output. + createdAt: + type: integer + format: int64 + description: Unix timestamp in milliseconds from compute job creation. + example: 1721506660000 + startedAt: + type: integer + format: int64 + description: Unix timestamp in milliseconds from when the compute job was started. + example: 1721506660000 + finishedAt: + type: integer + format: int64 + description: Unix timestamp in milliseconds from when the compute job finished. + example: 1721506660000 + consumerLongitude: + $ref: '#/components/schemas/consumerLongitude' + consumerLatitude: + $ref: '#/components/schemas/consumerLatitude' + co2EquivalentEmissionConsumer: + type: number + format: double + description: CO2 equivalent value of the consumer`s location. + example: 386.232323 + estimatedCo2Equivalent: + type: number + format: double + description: Final CO2 equivalent of the finished job. + example: 8.6563 + jobName: + type: string + description: A descriptive name for the compute job. + example: MyJob + + responses: + BadRequestError: + description: Bad Request - Request body has invalid format. + UnprocessableEntityError: + description: Unprocessable Content - The request body contains semantically invalid values. + UnauthorizedError: + description: Unauthorized. + InternalServerError: + description: Internal Server Error - Unknown internal error. Check the server log. diff --git a/services/consumer-gateway/go.mod b/services/consumer-gateway/go.mod new file mode 100644 index 0000000..2289e8d --- /dev/null +++ b/services/consumer-gateway/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 diff --git a/services/consumer-gateway/go.sum b/services/consumer-gateway/go.sum new file mode 100644 index 0000000..7128337 --- /dev/null +++ b/services/consumer-gateway/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/consumer-gateway/main.go b/services/consumer-gateway/main.go new file mode 100644 index 0000000..d61381f --- /dev/null +++ b/services/consumer-gateway/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + handler_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/adapters/handler-http" + rest_client_in_memory "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/adapters/rest-client-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/consumer-gateway/core" +) + +func main() { + communication := rest_client_in_memory.NewRestClient(make(map[string]string)) + consumerService := core.NewConsumerService(communication) + h := handler_http.NewHandler(consumerService) + http.Handle("/", h) + + srv := &http.Server{Addr: ":8080"} + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + log.Print("listening...") + srv.ListenAndServe() + log.Print("Done") +} diff --git a/services/consumer-gateway/ports/api.go b/services/consumer-gateway/ports/api.go new file mode 100644 index 0000000..00a5a74 --- /dev/null +++ b/services/consumer-gateway/ports/api.go @@ -0,0 +1,17 @@ +package ports + +import ( + "context" + "errors" +) + +var ErrJobNotFound = errors.New("job not found") +var ErrJobNotCreated = errors.New("job not created") +var ErrLoginFailed = errors.New("login failed") + +type Api interface { + GetJob(id string, ctx context.Context) (JobResponseDto, error) + GetJobs(ctx context.Context) ([]JobResponseDto, error) + CreateJob(createJobDto CreateJobDto, ctx context.Context) error + Login(authRequestDto AuthRequestDto, ctx context.Context) (AuthResponseDto, error) +} diff --git a/services/consumer-gateway/ports/model.go b/services/consumer-gateway/ports/model.go new file mode 100644 index 0000000..c55fd2b --- /dev/null +++ b/services/consumer-gateway/ports/model.go @@ -0,0 +1,83 @@ +package ports + +type JobDto struct { + Id string `json:"id"` + Name string `json:"name"` + ConsumerId string `json:"consumerId"` + WorkerId string `json:"workerId"` + ImageName string `json:"imageName"` + EnvironmentVariables map[string]string `json:"environmentVariables"` + Status JobStatus `json:"status"` + StandardOutput string `json:"standardOutput"` + + CreatedAt int64 `json:"createdAt"` + StartedAt int64 `json:"startedAt"` + FinishedAt int64 `json:"finishedAt"` + + ConsumerLongitude float64 `json:"consumerLongitude"` + ConsumerLatitude float64 `json:"consumerLatitude"` + Co2EquivalentEmissionConsumer float64 `json:"co2EquivalentEmissionConsumer"` + + WorkerLongitude float64 `json:"workerLongitude"` + WorkerLatitude float64 `json:"workerLatitude"` + Co2EquivalentEmissionWorker float64 `json:"co2EquivalentEmissionWorker"` + + EstimatedCo2Equivalent float64 `json:"estimatedCo2Equivalent"` +} + +type JobResponseDto struct { + Id string `json:"id"` + Name string `json:"name"` + ConsumerId string `json:"consumerId"` + ImageName string `json:"imageName"` + EnvironmentVariables map[string]string `json:"environmentVariables"` + Status JobStatus `json:"status"` + StandardOutput string `json:"standardOutput"` + + CreatedAt int64 `json:"createdAt"` + StartedAt int64 `json:"startedAt"` + FinishedAt int64 `json:"finishedAt"` + + ConsumerLongitude float64 `json:"consumerLongitude"` + ConsumerLatitude float64 `json:"consumerLatitude"` + Co2EquivalentEmissionConsumer float64 `json:"co2EquivalentEmissionConsumer"` + + EstimatedCo2Equivalent float64 `json:"estimatedCo2Equivalent"` +} + +type JobStatus string + +const ( + CREATED JobStatus = "CREATED" // job created, but not assigned to worker yet + PENDING JobStatus = "PENDING" // assigned to worker, but not running yet + RUNNING JobStatus = "RUNNING" + FINISHED JobStatus = "FINISHED" + FAILED JobStatus = "FAILED" +) + +type CreateJobDto struct { + RequestId string `json:"requestId"` + Job BaseJob `json:"job"` +} + +type BaseJob struct { + Name string `json:"name"` + ImageName string `json:"imageName"` + EnvironmentVariables map[string]string `json:"environmentVariables"` + ConsumerLongitude float64 `json:"consumerLongitude"` + ConsumerLatitude float64 `json:"consumerLatitude"` +} + +type AuthRequestDto struct { + RequestId string `json:"requestId"` + Credentials Credentials `json:"credentials"` +} + +type Credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type AuthResponseDto struct { + Token string +} diff --git a/services/consumer-gateway/ports/notifier.go b/services/consumer-gateway/ports/notifier.go new file mode 100644 index 0000000..1ff54fc --- /dev/null +++ b/services/consumer-gateway/ports/notifier.go @@ -0,0 +1,9 @@ +package ports + +import ( + "context" +) + +type Notifier interface { + JobCreated(job JobDto, ctx context.Context) +} diff --git a/services/consumer-gateway/ports/rest-client.go b/services/consumer-gateway/ports/rest-client.go new file mode 100644 index 0000000..a1c658c --- /dev/null +++ b/services/consumer-gateway/ports/rest-client.go @@ -0,0 +1,12 @@ +package ports + +import ( + "context" +) + +type RestClient interface { + GetJob(id string, ctx context.Context) (JobDto, error) + GetJobs(ctx context.Context) ([]JobDto, error) + CreateJob(createJobDto CreateJobDto, ctx context.Context) error + Login(authRequestDto AuthRequestDto, ctx context.Context) (AuthResponseDto, error) +} diff --git a/services/entity/README.md b/services/entity/README.md new file mode 100644 index 0000000..d1c3700 --- /dev/null +++ b/services/entity/README.md @@ -0,0 +1,13 @@ +# Entity Service + +The entity service is an example service that demonstrates the folder structure of a microservice following the ports & adapters architecture. + +> **WARNING** +> The implementation is in an early stage. Many things are still missing. Use with care. + +## Usage + +```bash +curl -X PUT -d '{ "Id": "34", "IntProp" : 23, "StringProp": "test" }' localhost:8080/entity +curl localhost:8080/entity/34 +``` diff --git a/services/entity/adapters/handler-http/handler.go b/services/entity/adapters/handler-http/handler.go new file mode 100644 index 0000000..6aa08e6 --- /dev/null +++ b/services/entity/adapters/handler-http/handler.go @@ -0,0 +1,55 @@ +package handler_http + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/ports" +) + +type Handler struct { + service ports.Api + rtr mux.Router +} + +var _ http.Handler = (*Handler)(nil) + +func NewHandler(service ports.Api) *Handler { + + h := Handler{service: service, rtr: *mux.NewRouter()} + h.rtr.HandleFunc("/entity/{id}", h.handleGet).Methods("GET") + h.rtr.HandleFunc("/entity", h.handleSet).Methods("PUT") + return &h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) //delegate +} + +func (h *Handler) handleSet(w http.ResponseWriter, r *http.Request) { + var entity ports.Entity + err := json.NewDecoder(r.Body).Decode(&entity) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + err = h.service.Set(entity, r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) +} + +func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + entity, err := h.service.Get(vars["id"], r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entity) +} diff --git a/services/entity/adapters/repo-in-memory/repo.go b/services/entity/adapters/repo-in-memory/repo.go new file mode 100644 index 0000000..9ab9264 --- /dev/null +++ b/services/entity/adapters/repo-in-memory/repo.go @@ -0,0 +1,32 @@ +package repo_in_memory + +import ( + "context" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/ports" +) + +type Repo struct { + entities map[string]ports.Entity +} + +var _ ports.Repo = (*Repo)(nil) + +func NewRepo() *Repo { + return &Repo{ + entities: make(map[string]ports.Entity), + } +} + +func (r *Repo) Store(entity ports.Entity, ctx context.Context) error { + r.entities[entity.Id] = entity + return nil +} + +func (r *Repo) FindById(id string, ctx context.Context) (ports.Entity, error) { + entity, ok := r.entities[id] + if !ok { + return ports.Entity{}, ports.ErrEntityNotFound + } + return entity, nil +} diff --git a/services/entity/core/entity.go b/services/entity/core/entity.go new file mode 100644 index 0000000..5947bfd --- /dev/null +++ b/services/entity/core/entity.go @@ -0,0 +1,41 @@ +package core + +import ( + "context" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/ports" +) + +type EntityService struct { + repo ports.Repo + notifier ports.Notifier +} + +func NewEntityService(repo ports.Repo, notifier ports.Notifier) *EntityService { + return &EntityService{ + repo: repo, + notifier: notifier, + } +} + +var _ ports.Api = (*EntityService)(nil) + +func (s *EntityService) Set(entity ports.Entity, ctx context.Context) error { + err := s.repo.Store(entity, ctx) + if err != nil { + return err + } + s.notifier.EntityChanged(entity, ctx) + return nil +} + +func (s *EntityService) Get(id string, ctx context.Context) (ports.Entity, error) { + entity, err := s.repo.FindById(id, ctx) + if err != nil { + return ports.Entity{}, err + } + if entity.Id != id { + return ports.Entity{}, ports.ErrEntityNotFound + } + return entity, nil +} diff --git a/services/entity/core/entity_test.go b/services/entity/core/entity_test.go new file mode 100644 index 0000000..b15cdfc --- /dev/null +++ b/services/entity/core/entity_test.go @@ -0,0 +1,149 @@ +package core_test + +import ( + "context" + "reflect" + "testing" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/core" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/ports" +) + +type MockRepo struct { + entity ports.Entity + requestedId string + err *error +} + +func (m *MockRepo) Store(entity ports.Entity, ctx context.Context) error { + m.entity = entity + if m.err != nil { + return *m.err + } + return nil +} + +func (m *MockRepo) FindById(id string, ctx context.Context) (ports.Entity, error) { + m.requestedId = id + if m.err != nil { + return ports.Entity{}, *m.err + } + return m.entity, nil +} + +var _ ports.Repo = (*MockRepo)(nil) + +type MockNotifier struct { + entity ports.Entity + callcount int +} + +func (m *MockNotifier) EntityChanged(entity ports.Entity, ctx context.Context) { + m.entity = entity + m.callcount++ +} + +var _ ports.Notifier = (*MockNotifier)(nil) + +func TestEntityService_Set(t *testing.T) { + + type fields struct { + repo ports.Repo + notifier ports.Notifier + } + + testFields := fields{&MockRepo{}, &MockNotifier{}} + ctx := context.Background() + + type args struct { + entity ports.Entity + ctx context.Context + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "Store some entity", + fields: testFields, + args: args{ + ports.Entity{Id: "1", IntProp: 4711, StringProp: "Test"}, + ctx, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := core.NewEntityService(tt.fields.repo, tt.fields.notifier) + + if err := s.Set(tt.args.entity, tt.args.ctx); (err != nil) != tt.wantErr { + t.Errorf("EntityService.Set() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.fields.repo.(*MockRepo).entity != tt.args.entity { + t.Errorf("EntityService.Set() repo entity = %v, want %v", tt.fields.repo.(*MockRepo).entity, tt.args.entity) + } + + if tt.fields.notifier.(*MockNotifier).entity != tt.args.entity { + t.Errorf("EntityService.Set() notifier entity = %v, want %v", tt.fields.notifier.(*MockNotifier).entity, tt.args.entity) + } + + if tt.fields.notifier.(*MockNotifier).callcount != 1 { + t.Errorf("EntityService.Set() notifier callcount = %v, want %v", tt.fields.notifier.(*MockNotifier).callcount, 1) + } + + }) + } +} + +func TestEntityService_Get(t *testing.T) { + type fields struct { + repo ports.Repo + notifier ports.Notifier + } + + testFields := fields{&MockRepo{entity: ports.Entity{Id: "25", IntProp: 23, StringProp: "test"}}, nil} + ctx := context.Background() + + type args struct { + id string + ctx context.Context + } + tests := []struct { + name string + fields fields + args args + want ports.Entity + wantErr bool + }{ + { + name: "Get existing entity", + fields: testFields, + args: args{ + "25", + ctx, + }, + want: ports.Entity{Id: "25", IntProp: 23, StringProp: "test"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := core.NewEntityService(tt.fields.repo, tt.fields.notifier) + got, err := s.Get(tt.args.id, tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("EntityService.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("EntityService.Get() = %v, want %v", got, tt.want) + } + if tt.fields.repo.(*MockRepo).requestedId != tt.args.id { + t.Errorf("EntityService.Get() repo requestedId = %v, want %v", tt.fields.repo.(*MockRepo).requestedId, tt.args.id) + } + }) + } +} diff --git a/services/entity/doc/api.md b/services/entity/doc/api.md new file mode 100644 index 0000000..0c06abb --- /dev/null +++ b/services/entity/doc/api.md @@ -0,0 +1,73 @@ +# REST API Documentation + +## Endpoint: Get Entity by ID +**Description:** Retrieve details of a specific entity by its ID. + +**URL:** `GET /entity/{id}` + +**Method:** `GET` + +**Auth:** To be implemented + +### URL Parameters +| Parameter | Type | Required | Description | +|-----------|--------|----------|------------------------------| +| id | string | Yes | The unique identifier of the entity | + +### Success Response +**Code:** `200 OK` + +**Content:** +```json +{ + "Id": "34", + "IntProp" : 23, + "StringProp": "test" +} +``` + +### Error Responses + +**Code:** `404 NOT FOUND` + +### Example call +`curl localhost:8080/entity/34` + + + +## Endpoint: Create/Update Entity +**Description:** Update details of a specific entity by its ID or create + +**URL:** `PUT /entity` + +**Method:** `PUT` + +**Auth:** To be implemented + +### Request Body + +**Content-Type:** `application/json` + +```json +{ + "Id": "34", + "IntProp" : 23, + "StringProp": "test" +} +``` + +### Success Response +**Code:** `201 CREATED` + +### Error Responses + +**Code:** `401 BAD REQUEST` + +**Cause:** Invalid input format. + +**Code:** `500 INTERNAL` + +**Cause:** Unknown internal error. Check the server log. + +### Example call +`curl -X PUT -d '{ "Id": "34", "IntProp" : 23, "StringProp": "test" }' localhost:8080/entity` diff --git a/services/entity/go.mod b/services/entity/go.mod new file mode 100644 index 0000000..8e98060 --- /dev/null +++ b/services/entity/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 diff --git a/services/entity/go.sum b/services/entity/go.sum new file mode 100644 index 0000000..7128337 --- /dev/null +++ b/services/entity/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/entity/main.go b/services/entity/main.go new file mode 100644 index 0000000..1396765 --- /dev/null +++ b/services/entity/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + handler_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/adapters/handler-http" + repo "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/adapters/repo-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/entity/core" +) + +func main() { + + core := core.NewEntityService(repo.NewRepo(), nil) + + srv := &http.Server{Addr: ":8080"} + + h := handler_http.NewHandler(core) + + http.Handle("/", h) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + log.Print("listening...") + srv.ListenAndServe() + log.Print("Done") +} diff --git a/services/entity/ports/api.go b/services/entity/ports/api.go new file mode 100644 index 0000000..259c248 --- /dev/null +++ b/services/entity/ports/api.go @@ -0,0 +1,13 @@ +package ports + +import ( + "context" + "errors" +) + +var ErrEntityNotFound = errors.New("entity not found") + +type Api interface { + Set(entity Entity, ctx context.Context) error + Get(id string, ctx context.Context) (Entity, error) +} diff --git a/services/entity/ports/model.go b/services/entity/ports/model.go new file mode 100644 index 0000000..378a687 --- /dev/null +++ b/services/entity/ports/model.go @@ -0,0 +1,7 @@ +package ports + +type Entity struct { + Id string + IntProp int + StringProp string +} diff --git a/services/entity/ports/notifier.go b/services/entity/ports/notifier.go new file mode 100644 index 0000000..86d9461 --- /dev/null +++ b/services/entity/ports/notifier.go @@ -0,0 +1,9 @@ +package ports + +import ( + "context" +) + +type Notifier interface { + EntityChanged(entity Entity, ctx context.Context) +} diff --git a/services/entity/ports/repo.go b/services/entity/ports/repo.go new file mode 100644 index 0000000..fdcd6a1 --- /dev/null +++ b/services/entity/ports/repo.go @@ -0,0 +1,10 @@ +package ports + +import ( + "context" +) + +type Repo interface { + Store(entity Entity, ctx context.Context) error + FindById(id string, ctx context.Context) (Entity, error) +} diff --git a/services/gcls-worker-daemon/adapters/client-http/client.go b/services/gcls-worker-daemon/adapters/client-http/client.go new file mode 100644 index 0000000..08e413d --- /dev/null +++ b/services/gcls-worker-daemon/adapters/client-http/client.go @@ -0,0 +1,264 @@ +package client_http + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/ports" +) + +type Client struct { + Config *ports.Config +} + +var _ ports.Client = (*Client)(nil) + +func NewClient(config *ports.Config) *Client { + return &Client{Config: config} +} + +func (c *Client) Register(worker ports.Worker) error { + gatewayURL, err := url.JoinPath(c.Config.GatewayUrl, "workers") + if err != nil { + return err + } + + payload, err := json.Marshal(worker) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, gatewayURL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + //TODO add authentication to Header + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated { + //TODO log Created + } else if resp.StatusCode == http.StatusBadRequest { + //TODO log Error + return errors.New("400 BadRequest content was unexpected") + } else if resp.StatusCode == http.StatusConflict { + //TODO log Error + return errors.New("409 Conflict Worker already exists") + } else if resp.StatusCode == http.StatusInternalServerError { + //TODO log Error + return errors.New("500 InternalServerError") + } else { + //TODO log Error + return errors.New(fmt.Sprintf("An unexpected value was returned %v", resp.StatusCode)) + } + return nil +} + +func (c *Client) Unregister(id string) error { + gatewayURL, urlErr := url.JoinPath(c.Config.GatewayUrl, "workers", id) + if urlErr != nil { + //TODO Handle URL Error + } + req, reqErr := http.NewRequest(http.MethodDelete, gatewayURL, nil) + if reqErr != nil { + //TODO Handle Error creating Request + } + //TODO add authentication to Header + + client := &http.Client{} + resp, clientErr := client.Do(req) + if clientErr != nil { + //TODO Hanlde HTTP Error + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent { + //TODO log Created + } else if resp.StatusCode == http.StatusForbidden { + //TODO log Error + return errors.New("403 Forbidden Worker was not deleted") + } else if resp.StatusCode == http.StatusNotFound { + //TODO log Error + return errors.New("404 NotFound Worker(id) Not found") + } else if resp.StatusCode == http.StatusInternalServerError { + //TODO log Error + return errors.New("500 InternalServerError") + } else { + //TODO log Error + return errors.New(fmt.Sprintf("An unexpected value was returned %v", resp.StatusCode)) + } + return nil +} + +func (c *Client) GetJobs(id string) ([]ports.Job, error) { + gatewayURL, urlErr := url.Parse(c.Config.GatewayUrl) + if urlErr != nil { + //TODO Handle URL Error + } + + //Add Query Parameter to URL + gatewayURL = gatewayURL.JoinPath("jobs") + query := gatewayURL.Query() + query.Set("id", id) + gatewayURL.RawQuery = query.Encode() + + req, reqErr := http.NewRequest(http.MethodGet, gatewayURL.String(), nil) + if reqErr != nil { + //TODO Handle Error creating Request + } + //TODO add authentication to Header + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + //TODO log Created + //Get Json Array from Body and unmarshall it into ports.Job Array + body, readError := io.ReadAll(resp.Body) + if readError != nil { + //TODO Handle Error + } + var jobs []ports.Job + jsonError := json.Unmarshal(body, &jobs) + if jsonError != nil { + //TODO Handle Error + } + + return jobs, nil + } else if resp.StatusCode == http.StatusNoContent { + //TODO log No new Job + } else if resp.StatusCode == http.StatusUnauthorized { + //TODO log Error + return nil, errors.New("401 AuthenticationError") + } else if resp.StatusCode == http.StatusForbidden { + //TODO log Error + return nil, errors.New("403 Forbidden Worker was not deleted") + } else if resp.StatusCode == http.StatusInternalServerError { + //TODO log Error + return nil, errors.New("500 InternalServerError") + } else { + //TODO log Error + return nil, errors.New(fmt.Sprintf("An unexpected value was returned %v", resp.StatusCode)) + } + + return nil, nil +} + +func (c *Client) UpdateStatus(id string, status ports.WorkerStatus) error { + gatewayURL, urlErr := url.JoinPath(c.Config.GatewayUrl, "workers", id) + if urlErr != nil { + //TODO Handle URL Error + } + patchStruct := struct { + Status ports.WorkerStatus `json:"status"` + }{ + Status: status, + } + payload, jsonError := json.Marshal(patchStruct) + if jsonError != nil { + //TODO Handle json Error + } + req, reqErr := http.NewRequest(http.MethodPatch, gatewayURL, bytes.NewReader(payload)) + if reqErr != nil { + //TODO Handle Error creating Request + } + req.Header.Set("Content-Type", "application/json") + + //TODO add authentication to Header + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated { + //TODO log Created + } else if resp.StatusCode == http.StatusBadRequest { + //TODO log Error + return errors.New("400 BadRequest content was unexpected") + } else if resp.StatusCode == http.StatusForbidden { + //TODO log Error + return errors.New("403 Forbidden") + } else if resp.StatusCode == http.StatusInternalServerError { + //TODO log Error + return errors.New("500 InternalServerError") + } else { + //TODO log Error + return errors.New(fmt.Sprintf("An unexpected value was returned %v", resp.StatusCode)) + } + return nil +} + +func (c *Client) UpdateJob(id string, stdout string, startedAt int64, finishedAt int64, status ports.JobStatus) error { + gatewayURL, urlErr := url.JoinPath(c.Config.GatewayUrl, "jobs", id) + if urlErr != nil { + //TODO Handle URL Error + } + patchStruct := struct { + Id string `json:"id"` + Status ports.JobStatus `json:"status"` + StartedAt int64 `json:"startedAt"` + FinishedAt int64 `json:"finishedAt"` + Stdout string `json:"stdout"` + }{ + Id: id, + Status: status, + StartedAt: startedAt, + FinishedAt: finishedAt, + Stdout: stdout, + } + payload, jsonError := json.Marshal(patchStruct) + if jsonError != nil { + //TODO Handle json Error + } + req, reqErr := http.NewRequest(http.MethodPatch, gatewayURL, bytes.NewReader(payload)) + if reqErr != nil { + //TODO Handle Error creating Request + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent { + //TODO log Created + } else if resp.StatusCode == http.StatusBadRequest { + //TODO log Error + return errors.New("400 BadRequest content was unexpected") + } else if resp.StatusCode == http.StatusUnauthorized { + //TODO log Error + return errors.New("401 Unauthorized") + } else if resp.StatusCode == http.StatusForbidden { + //TODO log Error + return errors.New("403 Forbidden") + } else if resp.StatusCode == http.StatusInternalServerError { + //TODO log Error + return errors.New("500 InternalServerError") + } else { + //TODO log Error + return errors.New(fmt.Sprintf("An unexpected value was returned %v", resp.StatusCode)) + } + return nil +} diff --git a/services/gcls-worker-daemon/adapters/executor-docker/executor.go b/services/gcls-worker-daemon/adapters/executor-docker/executor.go new file mode 100644 index 0000000..4ddf8c4 --- /dev/null +++ b/services/gcls-worker-daemon/adapters/executor-docker/executor.go @@ -0,0 +1,70 @@ +package executor_docker + +import ( + "bytes" + "log" + "os/exec" + "syscall" + "time" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/ports" +) + +type Executor struct{} + +var _ ports.Executor = (*Executor)(nil) + +func NewExecutor() *Executor { + return &Executor{} +} + +func (c *Executor) Execute(job ports.Job) (ports.ExecutionResult, error) { + + // Create an array to hold all the docker run arguments + // Use --rm to remove the container after it exits + args := []string{"run", "--rm", "--name", job.Id} + + // Add each environment variable as a separate -e flag for the cmd command + for key, value := range job.EnvironmentVariables { + args = append(args, "-e", key+"="+value) + } + + // Add the image name at the end of the arguments + args = append(args, job.ImageName) + + // Create the command with the appropriate arguments + cmd := exec.Command("docker", args...) + + var stdoutBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + + startedAt := time.Now().Unix() + + err := cmd.Run() + + finishedAt := time.Now().Unix() + + var status ports.ExecutionStatus + + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.Sys().(syscall.WaitStatus).ExitStatus() != 0 { + log.Printf("Container exited with non-zero status code %d\n", exitErr.Sys().(syscall.WaitStatus).ExitStatus()) + status = ports.FAILURE + } + } else { + log.Fatalf("Failed to run docker %s: %v", job.Id, err) + status = ports.FAILURE + } + } else { + log.Println("Container exited with status code 0") + status = ports.SUCCESS + } + + return ports.ExecutionResult{ + Status: status, + Stdout: stdoutBuf.String(), + StartedAt: startedAt, + FinishedAt: finishedAt, + }, nil +} diff --git a/services/gcls-worker-daemon/config/config.go b/services/gcls-worker-daemon/config/config.go new file mode 100644 index 0000000..09dd2e0 --- /dev/null +++ b/services/gcls-worker-daemon/config/config.go @@ -0,0 +1,84 @@ +package config + +import ( + "fmt" + "os" + "reflect" + "strconv" +) + +type Config struct { + Longitude float64 `env:"LONGITUDE" default:"10.451526"` + Latitude float64 `env:"LATITUDE" default:"51.165691"` +} + +// loads the configuration into the struct +// this works with string, int, float32, and float64 +// docker environment can be used to set the values +// supports default values +func LoadConfig(config any) error { + + // ensure the provided config is a pointer to a struct + value := reflect.ValueOf(config) + if value.Kind() != reflect.Ptr || value.Elem().Kind() != reflect.Struct { + return fmt.Errorf("config must be a pointer to a struct") + } + + configValue := value.Elem() + configType := configValue.Type() + + for i := 0; i < configValue.NumField(); i++ { + fieldValue := configValue.Field(i) + fieldType := configType.Field(i) + // check if the field has an env tag + envName := fieldType.Tag.Get("env") + if envName == "" { + continue + } + // get the value from the environment + value, isSet := os.LookupEnv(envName) + // if the value is empty and the field is settable, try to set the default value + if !isSet && fieldValue.CanSet() { + value = fieldType.Tag.Get("default") + + if value == "" { + return fmt.Errorf("environment variable %s is not set and no default value provided", envName) + } + } + + // check the field type and set the value accordingly + switch fieldType.Type.Kind() { + + case reflect.String: + if !fieldValue.CanSet() { + return fmt.Errorf("cannot set field %s", fieldType.Name) + } + fieldValue.SetString(value) + + case reflect.Int: + intValue, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid value for field %s: %v", fieldType.Name, err) + } + if !fieldValue.CanSet() { + return fmt.Errorf("cannot set field %s", fieldType.Name) + } + fieldValue.SetInt(int64(intValue)) + + case reflect.Float64: + floatValue, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid value for field %s: %v", fieldType.Name, err) + } + if !fieldValue.CanSet() { + return fmt.Errorf("cannot set field %s", fieldType.Name) + } + fieldValue.SetFloat(floatValue) + + default: + return fmt.Errorf("unsupported field type %s for field %s", fieldType.Type.Kind(), fieldType.Name) + } + } + + return nil +} diff --git a/services/gcls-worker-daemon/core/worker_daemon.go b/services/gcls-worker-daemon/core/worker_daemon.go new file mode 100644 index 0000000..62c370d --- /dev/null +++ b/services/gcls-worker-daemon/core/worker_daemon.go @@ -0,0 +1,105 @@ +package core + +import ( + "context" + "time" + + "github.com/google/uuid" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/ports" +) + +type WorkerDaemon struct { + worker ports.Worker + client ports.Client + executor ports.Executor +} + +func NewWorkerDaemon(client ports.Client, executor ports.Executor, config *ports.Config) *WorkerDaemon { + return &WorkerDaemon{ + ports.Worker{ + Id: uuid.NewString(), + Status: ports.EXHAUSTED, + Location: ports.Location{Longitude: config.Longitude, Latitude: config.Latitude}, + }, + client, + executor, + } +} + +func (w *WorkerDaemon) Start(ctx context.Context, done chan struct{}) { + defer close(done) + + if err := w.client.Register(w.worker); err != nil { + // TODO: log + return + } + + for { + select { + case <-ctx.Done(): + if err := w.client.Unregister(w.worker.Id); err != nil { + // TODO: log + } + return + default: + jobs, err := w.client.GetJobs(w.worker.Id) + if err != nil { + // TODO: log + return + } + + if len(jobs) != 0 { + if err := w.updateStatus(ports.EXHAUSTED); err != nil { + // TODO: log + return + } + } else { + if err := w.updateStatus(ports.REQUIRES_WORK); err != nil { + // TODO: log + return + } + + time.Sleep(5 * time.Second) + } + + if err := w.executeJobs(jobs); err != nil { + // TODO: log + return + } + } + } +} + +func (w *WorkerDaemon) executeJobs(jobs []ports.Job) error { + for _, job := range jobs { + result, err := w.executor.Execute(job) + if err != nil { + // TODO: log + return err + } + + status := ports.FAILED + if result.Status == ports.SUCCESS { + status = ports.FINISHED + } + + if err := w.client.UpdateJob(job.Id, result.Stdout, result.StartedAt, result.FinishedAt, status); err != nil { + // TODO: log + return err + } + } + + return nil +} + +func (w *WorkerDaemon) updateStatus(status ports.WorkerStatus) error { + w.worker.Status = status + + if err := w.client.UpdateStatus(w.worker.Id, w.worker.Status); err != nil { + // TODO: log + return err + } + + return nil +} diff --git a/services/gcls-worker-daemon/core/worker_daemon_test.go b/services/gcls-worker-daemon/core/worker_daemon_test.go new file mode 100644 index 0000000..aa581d3 --- /dev/null +++ b/services/gcls-worker-daemon/core/worker_daemon_test.go @@ -0,0 +1 @@ +package core_test diff --git a/services/gcls-worker-daemon/go.mod b/services/gcls-worker-daemon/go.mod new file mode 100644 index 0000000..9978338 --- /dev/null +++ b/services/gcls-worker-daemon/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon + +go 1.23.2 + +require github.com/google/uuid v1.6.0 diff --git a/services/gcls-worker-daemon/go.sum b/services/gcls-worker-daemon/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/services/gcls-worker-daemon/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/services/gcls-worker-daemon/main.go b/services/gcls-worker-daemon/main.go new file mode 100644 index 0000000..18b7dcd --- /dev/null +++ b/services/gcls-worker-daemon/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + client_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/adapters/client-http" + executor_docker "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/adapters/executor-docker" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/config" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/core" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/gcls-worker-daemon/ports" +) + +func main() { + + cfg := &ports.Config{} + config.LoadConfig(cfg) + + fmt.Printf("Starting worker daemon with config: %+v\n", cfg) + + worker := core.NewWorkerDaemon(client_http.NewClient(cfg), executor_docker.NewExecutor(), cfg) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + cancel() + }() + + go worker.Start(ctx, done) + <-done +} diff --git a/services/gcls-worker-daemon/ports/client.go b/services/gcls-worker-daemon/ports/client.go new file mode 100644 index 0000000..b0d9d47 --- /dev/null +++ b/services/gcls-worker-daemon/ports/client.go @@ -0,0 +1,9 @@ +package ports + +type Client interface { + Register(worker Worker) error + Unregister(id string) error + GetJobs(id string) ([]Job, error) + UpdateStatus(id string, status WorkerStatus) error + UpdateJob(id string, stdout string, startedAt int64, finishedAt int64, status JobStatus) error +} diff --git a/services/gcls-worker-daemon/ports/executor.go b/services/gcls-worker-daemon/ports/executor.go new file mode 100644 index 0000000..6ea9709 --- /dev/null +++ b/services/gcls-worker-daemon/ports/executor.go @@ -0,0 +1,5 @@ +package ports + +type Executor interface { + Execute(job Job) (ExecutionResult, error) +} diff --git a/services/gcls-worker-daemon/ports/model.go b/services/gcls-worker-daemon/ports/model.go new file mode 100644 index 0000000..a689549 --- /dev/null +++ b/services/gcls-worker-daemon/ports/model.go @@ -0,0 +1,62 @@ +package ports + +type Config struct { + Longitude float64 `env:"LONGITUDE" default:"10.451526"` + Latitude float64 `env:"LATITUDE" default:"51.165691"` + GatewayUrl string `env:"GATEWAY_URL" default:"http://localhost:8080"` +} + +type Worker struct { + Id string `json:"id"` + Status WorkerStatus `json:"status"` + Location Location `json:"location"` +} + +type Location struct { + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` +} + +type Job struct { + Id string `json:"Id"` + ImageName string `json:"ImageName"` + EnvironmentVariables map[string]string `json:"EnvironmentVariables"` + Status JobStatus `json:"Status"` + StartedAt int64 `json:"StartedAt"` + FinishedAt int64 `json:"FinishedAt"` +} + +type JobStatus string + +const ( + CREATED JobStatus = "CREATED" + PENDING JobStatus = "PENDING" + RUNNING JobStatus = "RUNNING" + FINISHED JobStatus = "FINISHED" + FAILED JobStatus = "FAILED" +) + +type WorkerPatch struct { + Status WorkerStatus `json:"status"` +} + +type WorkerStatus string + +const ( + REQUIRES_WORK WorkerStatus = "REQUIRES_WORK" + EXHAUSTED WorkerStatus = "EXHAUSTED" +) + +type ExecutionResult struct { + Status ExecutionStatus + Stdout string + StartedAt int64 + FinishedAt int64 +} + +type ExecutionStatus string + +const ( + FAILURE ExecutionStatus = "FAILURE" + SUCCESS ExecutionStatus = "SUCCESS" +) diff --git a/services/job-scheduler/README.md b/services/job-scheduler/README.md new file mode 100644 index 0000000..463f713 --- /dev/null +++ b/services/job-scheduler/README.md @@ -0,0 +1 @@ +# Job Scheduler diff --git a/services/job-scheduler/adapters/handler-http/handler.go b/services/job-scheduler/adapters/handler-http/handler.go new file mode 100644 index 0000000..8a6ab0e --- /dev/null +++ b/services/job-scheduler/adapters/handler-http/handler.go @@ -0,0 +1,121 @@ +package handler_http + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job-scheduler/ports" +) + +type Handler struct { + service ports.Api + rtr mux.Router +} + +var _ http.Handler = (*Handler)(nil) + +func NewHandler(service ports.Api) *Handler { + + h := Handler{service: service, rtr: *mux.NewRouter()} + //h.rtr.HandleFunc("/jobScheduler/{id}", h.handleGet).Methods("GET") + //h.rtr.HandleFunc("/jobScheduler", h.handleSet).Methods("PUT") + h.rtr.HandleFunc("/workers", h.handleGetWorkers).Methods("GET") + h.rtr.HandleFunc("/jobs", h.handleGetOpenJobs).Methods("GET") + h.rtr.HandleFunc("/emissions", h.handleGetEmissions).Methods("GET") + return &h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) +} + +func (h *Handler) handleGetWorkers(w http.ResponseWriter, r *http.Request) { + requiredStatus := ports.WorkerStatus("REQUIRES_WORK") + + workers, err := h.service.GetWorkers(requiredStatus, r.Context()) + if err != nil { + http.Error(w, "Failed to get workers", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(workers); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +func (h *Handler) handleGetOpenJobs(w http.ResponseWriter, r *http.Request) { + jobs, err := h.service.GetOpenJobs(r.Context()) + if err != nil { + http.Error(w, "Failed to fetch jobs: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(jobs); err != nil { + http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + } +} + +func (h *Handler) handleGetEmissions(w http.ResponseWriter, r *http.Request) { + latStr := r.URL.Query().Get("latitude") + lonStr := r.URL.Query().Get("longitude") + + if latStr == "" || lonStr == "" { + http.Error(w, "Latitude and Longitude query parameters are required", http.StatusBadRequest) + return + } + + latitude, err := strconv.ParseFloat(latStr, 64) + if err != nil { + http.Error(w, "Invalid latitude parameter", http.StatusBadRequest) + return + } + + longitude, err := strconv.ParseFloat(lonStr, 64) + if err != nil { + http.Error(w, "Invalid longitude parameter", http.StatusBadRequest) + return + } + + emissionData, err := h.service.GetEmissions(latitude, longitude, r.Context()) + if err != nil { + http.Error(w, "Failed to fetch emissions data: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(emissionData); err != nil { + http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + } +} + +/* func (h *Handler) handleSet(w http.ResponseWriter, r *http.Request) { + var jobScheduler ports.JobScheduler + err := json.NewDecoder(r.Body).Decode(&jobScheduler) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + err = h.service.Set(jobScheduler, r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) +} + +func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + jobScheduler, err := h.service.Get(vars["id"], r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jobScheduler) +} */ diff --git a/services/job-scheduler/adapters/repo-in-memory/repo.go b/services/job-scheduler/adapters/repo-in-memory/repo.go new file mode 100644 index 0000000..3afbb53 --- /dev/null +++ b/services/job-scheduler/adapters/repo-in-memory/repo.go @@ -0,0 +1,32 @@ +package repo_in_memory + +import ( + "context" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job-scheduler/ports" +) + +type Repo struct { + jobSchedulers map[string]ports.JobScheduler +} + +var _ ports.Repo = (*Repo)(nil) + +func NewRepo() *Repo { + return &Repo{ + jobSchedulers: make(map[string]ports.JobScheduler), + } +} + +func (r *Repo) Store(jobScheduler ports.JobScheduler, ctx context.Context) error { + r.jobSchedulers[jobScheduler.Id] = jobScheduler + return nil +} + +func (r *Repo) FindById(id string, ctx context.Context) (ports.JobScheduler, error) { + jobScheduler, ok := r.jobSchedulers[id] + if !ok { + return ports.JobScheduler{}, ports.ErrJobSchedulerNotFound + } + return jobScheduler, nil +} diff --git a/services/job-scheduler/core/job-scheduler.go b/services/job-scheduler/core/job-scheduler.go new file mode 100644 index 0000000..015d661 --- /dev/null +++ b/services/job-scheduler/core/job-scheduler.go @@ -0,0 +1,128 @@ +package core + +import ( + "context" + "fmt" + "math" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job-scheduler/ports" +) + +type JobSchedulerService struct { + repo ports.Repo + notifier ports.Notifier + api ports.Api + scheduler ports.Scheduler +} + +func NewJobSchedulerService(repo ports.Repo, notifier ports.Notifier, api ports.Api, scheduler ports.Scheduler) *JobSchedulerService { + return &JobSchedulerService{ + repo: repo, + notifier: notifier, + api: api, + scheduler: scheduler, + } +} + +func (s *JobSchedulerService) GetWorkers(status ports.WorkerStatus, ctx context.Context) ([]ports.WorkerToSchedule, error) { + workers, err := s.api.GetWorkers(status, ctx) + if err != nil { + return nil, err + } + + return workers, nil +} + +func (s *JobSchedulerService) GetOpenJobs(ctx context.Context) ([]ports.JobToSchedule, error) { + jobs, err := s.api.GetOpenJobs(ctx) + if err != nil { + return nil, err + } + + var pendingJobs []ports.JobToSchedule + for _, job := range jobs { + if job.Status == "PENDING" { + pendingJobs = append(pendingJobs, job) + } + } + + return pendingJobs, nil +} + +func (s *JobSchedulerService) GetEmissions(latitude float64, longitude float64, ctx context.Context) (ports.CipToSchedule, error) { + EmissionData, err := s.api.GetEmissions(latitude, longitude, ctx) + if err != nil { + return ports.CipToSchedule{}, err + } + + return EmissionData, nil +} + +func (s *JobSchedulerService) InitializeScheduler(workers []ports.WorkerToSchedule, jobs []ports.JobToSchedule, emissions []ports.CipToSchedule) { + // s.scheduler.Jobs = make(map[string]JobToSchedule) + // s.scheduler.Workers = make(map[string]WorkerToSchedule) + // s.scheduler.Cip = make(map[string]CipToSchedule) + + for _, worker := range workers { + s.scheduler.Workers[worker.ID] = worker + } + + for _, job := range jobs { + s.scheduler.Jobs[job.Id] = job + } + + for _, emission := range emissions { + + if emission.JobID != "" { + s.scheduler.Cip[emission.JobID] = emission + } + } + +} + +func (s *JobSchedulerService) ScheduleJobs() error { + for jobID, job := range s.scheduler.Jobs { + var bestWorkerID string + minEmission := math.MaxFloat64 + + for workerID := range s.scheduler.Workers { + key := jobID + emissionData, exists := s.scheduler.Cip[key] + if !exists { + continue + } + + emission := emissionData.CarbonIntensity + if emission < minEmission { + minEmission = emission + bestWorkerID = workerID + } + } + + if bestWorkerID == "" { + fmt.Printf("No Worker found for Job: %s \n", jobID) + continue + } + + job.WorkerId = bestWorkerID + s.scheduler.Jobs[jobID] = job + + updateRequest := ports.UpdateJobRequest{ + Status: "SCHEDULED", + WorkerId: bestWorkerID, + } + + updatedJob, err := s.api.UpdateJob(context.Background(), jobID, updateRequest, "system", "admin") + if err != nil { + return fmt.Errorf("failed to update job %s: %v", jobID, err) + } + + fmt.Printf("Job %s got assigned to Worker %s (Emissionen: %.2f). Updated in repository.\n", updatedJob.Id, updatedJob.WorkerId, minEmission) + + s.scheduler.Matches = append(s.scheduler.Matches, ports.Match{ + JobID: updatedJob.Id, + WorkerID: updatedJob.WorkerId, + }) + } + return nil +} diff --git a/services/job-scheduler/go.mod b/services/job-scheduler/go.mod new file mode 100644 index 0000000..7b2cc50 --- /dev/null +++ b/services/job-scheduler/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job-scheduler + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 diff --git a/services/job-scheduler/go.sum b/services/job-scheduler/go.sum new file mode 100644 index 0000000..7128337 --- /dev/null +++ b/services/job-scheduler/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/job-scheduler/main.go b/services/job-scheduler/main.go new file mode 100644 index 0000000..f9d3fac --- /dev/null +++ b/services/job-scheduler/main.go @@ -0,0 +1,25 @@ +package main + +func main() { + /* + core := core.NewJobSchedulerService(repo.NewRepo(), notifier.NewN nil) + + srv := &http.Server{Addr: ":8080"} + + h := handler_http.NewHandler(core) + http.Handle("/", h) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + log.Print("listening...") + srv.ListenAndServe() + log.Print("Done") + */ +} diff --git a/services/job-scheduler/ports/api.go b/services/job-scheduler/ports/api.go new file mode 100644 index 0000000..30ddbbc --- /dev/null +++ b/services/job-scheduler/ports/api.go @@ -0,0 +1,19 @@ +package ports + +import ( + "context" + "errors" +) + +var ErrJobSchedulerNotFound = errors.New("entity not found") + +type Api interface { + //Set(jobScheduler JobScheduler, ctx context.Context) error + //Get(id string, ctx context.Context) (JobScheduler, error) + + GetWorkers(status WorkerStatus, ctx context.Context) ([]WorkerToSchedule, error) + GetOpenJobs(ctx context.Context) ([]JobToSchedule, error) + GetEmissions(latitude float64, longitude float64, ctx context.Context) (CipToSchedule, error) + UpdateJobRequest(JobStatus string, WorkerId string) + UpdateJob(ctx context.Context, jobID string, request UpdateJobRequest, updatedBy string, role string) (JobToSchedule, error) +} diff --git a/services/job-scheduler/ports/model.go b/services/job-scheduler/ports/model.go new file mode 100644 index 0000000..0c0e6a7 --- /dev/null +++ b/services/job-scheduler/ports/model.go @@ -0,0 +1,67 @@ +package ports + +import ( + "sync" +) + +type JobStatus string +type WorkerStatus string +type JobScheduler struct { + Id string + IntProp int + StringProp string +} +type JobToSchedule struct { + Id string `json:"id"` + Name string `json:"name"` + ConsumerId string `json:"consumer_id"` + WorkerId string `json:"worker_id"` + ImageName string `json:"image_name"` + EnvironmentVariables map[string]string `json:"environment_variables"` + Status JobStatus `json:"status"` + StandardOutput string `json:"standard_output"` + + CreatedAt int64 `json:"created_at"` + + StartedAt int64 `json:"started_at"` + FinishedAt int64 `json:"finished_at"` + + ConsumerLocation string `json:"consumer_location"` + Co2EquivalentEmissionConsumer float64 `json:"co2_equivalent_emission_consumer"` + + WorkerLocation string `json:"worker_location"` + Co2EquivalentEmissionWorker float64 `json:"co2_equivalent_emission_worker"` + + EstimatedCo2Equivalent float64 `json:"estimated_co2_equivalent"` +} + +type WorkerToSchedule struct { + ID string `json:"id"` + Status WorkerStatus `json:"status"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +type CipToSchedule struct { + CarbonIntensity float64 `json:"carbonIntensity"` + UpdatedAt string `json:"updatedAt"` + JobID string `json:"jobID"` +} + +type Match struct { + JobID string `json:"job_id"` + WorkerID string `json:"worker_id"` +} + +type Scheduler struct { + Jobs map[string]JobToSchedule // map of job id to job + Workers map[string]WorkerToSchedule // map of worker id to worker + Cip map[string]CipToSchedule + Matches []Match // list of matches + Mutex sync.RWMutex // mutex to protect the maps +} + +type UpdateJobRequest struct { + Status string + WorkerId string +} diff --git a/services/job-scheduler/ports/notifier.go b/services/job-scheduler/ports/notifier.go new file mode 100644 index 0000000..9f4424a --- /dev/null +++ b/services/job-scheduler/ports/notifier.go @@ -0,0 +1,9 @@ +package ports + +import ( + "context" +) + +type Notifier interface { + JobSchedulerChanged(jobScheduler JobScheduler, ctx context.Context) +} diff --git a/services/job-scheduler/ports/repo.go b/services/job-scheduler/ports/repo.go new file mode 100644 index 0000000..d4118ed --- /dev/null +++ b/services/job-scheduler/ports/repo.go @@ -0,0 +1,10 @@ +package ports + +import ( + "context" +) + +type Repo interface { + Store(jobScheduler JobScheduler, ctx context.Context) error + FindById(id string, ctx context.Context) (JobScheduler, error) +} diff --git a/services/job/README.md b/services/job/README.md new file mode 100644 index 0000000..d1c3700 --- /dev/null +++ b/services/job/README.md @@ -0,0 +1,13 @@ +# Entity Service + +The entity service is an example service that demonstrates the folder structure of a microservice following the ports & adapters architecture. + +> **WARNING** +> The implementation is in an early stage. Many things are still missing. Use with care. + +## Usage + +```bash +curl -X PUT -d '{ "Id": "34", "IntProp" : 23, "StringProp": "test" }' localhost:8080/entity +curl localhost:8080/entity/34 +``` diff --git a/services/job/adapters/handler-http/handler.go b/services/job/adapters/handler-http/handler.go new file mode 100644 index 0000000..3235217 --- /dev/null +++ b/services/job/adapters/handler-http/handler.go @@ -0,0 +1,104 @@ +package handler_http + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/gorilla/mux" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/ports" +) + +type Handler struct { + service ports.Api + rtr *mux.Router +} + +var _ http.Handler = (*Handler)(nil) + +func NewHandler(service ports.Api) *Handler { + h := &Handler{service: service, rtr: mux.NewRouter()} + + h.rtr.HandleFunc("/jobs", h.createJobHandler).Methods("POST") + h.rtr.HandleFunc("/jobs", h.getJobsHandler).Methods("GET") + h.rtr.HandleFunc("/jobs/{id}", h.getJobDetailsHandler).Methods("GET") + h.rtr.HandleFunc("/jobs/{id}", h.updateJobHandler).Methods("PATCH") + + return h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) +} + +// createJobHandler +func (h *Handler) createJobHandler(w http.ResponseWriter, r *http.Request) { + // JWT Authentication + role validation + // extract consumerId from JWT + + var reqBody struct { + Name string `json:"name"` + ImageName string `json:"imageName"` + EnvironmentVariables map[string]string `json:"environmentVariables"` + ConsumerLongitude float64 `json:"ConsumerLongitude"` + ConsumerLatitude float64 `json:"ConsumerLatitude"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "Invalid job definition", http.StatusBadRequest) + return + } + + // TODO Business logic to create a job + + // Set response headers and status + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"message": "Job created successfully"}`)) +} + +// getJobsHandler +func (h *Handler) getJobsHandler(w http.ResponseWriter, r *http.Request) { + // JWT Authentication + role validation + // TODO role-based job retrieve logic + + w.Header().Set("Content-Type", "application/json") + // logic to retrieve jobs + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": "exampleId", "name": "exampleJob", "consumerLocation": "exampleLocation", "createdAt": time.Now().Unix()}, + }) +} + +// getJobDetailsHandler +func (h *Handler) getJobDetailsHandler(w http.ResponseWriter, r *http.Request) { + // JWT Authentication and role validation + vars := mux.Vars(r) + jobID := vars["id"] + + //logic to retrieve job details + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": jobID, + "status": "active", + "name": "exampleJob", + }) +} + +// updateJobHandler +func (h *Handler) updateJobHandler(w http.ResponseWriter, r *http.Request) { + // JWT Authentication + role validation + //vars := mux.Vars(r) + //jobID := vars["id"] + + var reqBody struct { + //... + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "Invalid input", http.StatusBadRequest) + return + } + + //logic to update job + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "Job updated successfully"}`)) +} diff --git a/services/job/adapters/repo-in-memory/repo.go b/services/job/adapters/repo-in-memory/repo.go new file mode 100644 index 0000000..1b23db2 --- /dev/null +++ b/services/job/adapters/repo-in-memory/repo.go @@ -0,0 +1,96 @@ +// The mockJobs are temporary in the future we will only fetch the necessary values for the request from the database + +package repo_in_memory + +import ( + "context" + "sync" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/ports" +) + +type Repo struct { + mu sync.RWMutex + jobs map[string]ports.Job +} + +var _ ports.Repo = (*Repo)(nil) + +func NewRepo() *Repo { + return &Repo{ + jobs: make(map[string]ports.Job), + } +} + +func (r *Repo) Store(job ports.Job, ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + r.jobs[job.Id] = job + return nil +} + +func (r *Repo) FindById(id string, ctx context.Context) (ports.Job, error) { + r.mu.RLock() + defer r.mu.RUnlock() + job, ok := r.jobs[id] + if !ok { + return ports.Job{}, ports.ErrJobNotFound + } + return job, nil +} + +// FindByConsumerId +func (r *Repo) FindByConsumerId(consumerID string, ctx context.Context) ([]ports.Job, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var jobs []ports.Job + for _, job := range r.jobs { + if job.ConsumerId == consumerID { + jobs = append(jobs, job) + } + } + return jobs, nil +} + +// FindByWorkerId +func (r *Repo) FindByWorkerId(workerID string, ctx context.Context) ([]ports.Job, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var jobs []ports.Job + for _, job := range r.jobs { + if job.WorkerId == workerID && job.Status == ports.PENDING { + mockJob := ports.Job{ + Id: job.Id, + ImageName: job.ImageName, + EnvironmentVariables: job.EnvironmentVariables, + CreatedAt: job.CreatedAt, + } + jobs = append(jobs, mockJob) + job.Status = ports.RUNNING + } + } + return jobs, nil +} + +// FindOpenJobs +func (r *Repo) FindOpenJobs(ctx context.Context) ([]ports.Job, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var openJobs []ports.Job + for _, job := range r.jobs { + if job.Status == ports.CREATED { + mockJob := ports.Job{ + Id: job.Id, + CreatedAt: job.CreatedAt, + ConsumerLocation: job.ConsumerLocation, + } + openJobs = append(openJobs, mockJob) + r.Store(job, ctx) + } + + } + return openJobs, nil +} diff --git a/services/job/core/job.go b/services/job/core/job.go new file mode 100644 index 0000000..35cf53e --- /dev/null +++ b/services/job/core/job.go @@ -0,0 +1,118 @@ +// All values like for example consumerID that are at the moment given to the functions manually will +// in future be extracted or fetched via the values of the JWT tokens this is just for testing purposes +package core + +import ( + "context" + "time" + + "github.com/google/uuid" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/ports" +) + +type JobService struct { + repo ports.Repo +} + +func NewJobService(repo ports.Repo) *JobService { + return &JobService{repo: repo} +} + +var _ ports.Api = (*JobService)(nil) + +// CreateJob +func (s *JobService) CreateJob(ctx context.Context, param ports.CreateJobParams, consumerID string) error { + consumerLocation := ports.Location{ + Longitude: param.ConsumerLongitude, + Latitude: param.ConsumerLatitude, + } + + job := ports.Job{ + Id: uuid.NewString(), + Name: param.Name, + ConsumerId: consumerID, + ImageName: param.ImageName, + EnvironmentVariables: param.EnvironmentVariables, + Status: ports.CREATED, + CreatedAt: time.Now().Unix(), + ConsumerLocation: consumerLocation, + } + if err := s.repo.Store(job, ctx); err != nil { + return err + } + + return nil +} + +// GetJob unnecessary for now? +/* +func (s *JobService) GetJob(ctx context.Context, id string) (ports.Job, error) { + job, err := s.repo.FindById(id, ctx) + if err != nil { + return ports.Job{}, err + } + if job.Id != id { + return ports.Job{}, ports.ErrJobNotFound + } + return job, nil +} +*/ + +// UpdateJob +func (s *JobService) UpdateJobForScheduler(ctx context.Context, id string, param ports.UpdateJobForSchedulerParams) error { + job, err := s.repo.FindById(id, ctx) + if err != nil { + return ports.ErrJobNotFound + } + + job.WorkerId = param.WorkerId + job.Co2EquivalentEmissionConsumer = param.Co2EquivalentEmissionConsumer + job.WorkerLocation = param.WorkerLocation + job.Co2EquivalentEmissionWorker = param.Co2EquivalentEmissionWorker + job.Status = ports.PENDING + + if err := s.repo.Store(job, ctx); err != nil { + return err + } + + return nil +} + +func (s *JobService) UpdateJobForWorker(ctx context.Context, id string, param ports.UpdateJobForWorkerParams) error { + job, err := s.repo.FindById(id, ctx) + if err != nil { + return ports.ErrJobNotFound + } + + job.StandardOutput = param.StandardOutput + job.StartedAt = param.StartedAt + job.StartedAt = param.FinishedAt + // Co² equivalent to be implemented we don't know calculation yet + + job.Status = param.Status + + if err := s.repo.Store(job, ctx); err != nil { + return err + } + + return nil +} + +// GetJobsForConsumer +func (s *JobService) GetJobsForConsumer(ctx context.Context, consumerID string) ([]ports.Job, error) { + // JWT validation logic + return s.repo.FindByConsumerId(consumerID, ctx) +} + +// GetJobsForWorker +func (s *JobService) GetJobsForWorker(ctx context.Context, workerID string) ([]ports.Job, error) { + // JWT validation logic + return s.repo.FindByWorkerId(workerID, ctx) +} + +// GetOpenJobs +func (s *JobService) GetOpenJobs(ctx context.Context) ([]ports.Job, error) { + // JWT validation logic + return s.repo.FindOpenJobs(ctx) +} diff --git a/services/job/doc/api.md b/services/job/doc/api.md new file mode 100644 index 0000000..0c06abb --- /dev/null +++ b/services/job/doc/api.md @@ -0,0 +1,73 @@ +# REST API Documentation + +## Endpoint: Get Entity by ID +**Description:** Retrieve details of a specific entity by its ID. + +**URL:** `GET /entity/{id}` + +**Method:** `GET` + +**Auth:** To be implemented + +### URL Parameters +| Parameter | Type | Required | Description | +|-----------|--------|----------|------------------------------| +| id | string | Yes | The unique identifier of the entity | + +### Success Response +**Code:** `200 OK` + +**Content:** +```json +{ + "Id": "34", + "IntProp" : 23, + "StringProp": "test" +} +``` + +### Error Responses + +**Code:** `404 NOT FOUND` + +### Example call +`curl localhost:8080/entity/34` + + + +## Endpoint: Create/Update Entity +**Description:** Update details of a specific entity by its ID or create + +**URL:** `PUT /entity` + +**Method:** `PUT` + +**Auth:** To be implemented + +### Request Body + +**Content-Type:** `application/json` + +```json +{ + "Id": "34", + "IntProp" : 23, + "StringProp": "test" +} +``` + +### Success Response +**Code:** `201 CREATED` + +### Error Responses + +**Code:** `401 BAD REQUEST` + +**Cause:** Invalid input format. + +**Code:** `500 INTERNAL` + +**Cause:** Unknown internal error. Check the server log. + +### Example call +`curl -X PUT -d '{ "Id": "34", "IntProp" : 23, "StringProp": "test" }' localhost:8080/entity` diff --git a/services/job/doc/api.yaml b/services/job/doc/api.yaml new file mode 100644 index 0000000..432f54c --- /dev/null +++ b/services/job/doc/api.yaml @@ -0,0 +1,327 @@ +openapi: 3.0.1 +info: + title: Job Management API + version: '1.0' + description: > + API for managing jobs with consumer and worker roles authenticated via JWT tokens. + This API enforces the principle of least privilege by restricting actions based on user roles and job statuses. + +servers: + - url: http://localhost:8080 + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + JobStatus: + type: string + enum: [PENDING] + + Job: + type: object + properties: + id: + type: string + example: jobId123 + name: + type: string + example: "Data Processing Job" + consumerId: + type: string + example: consumer123 + workerId: + type: string + example: worker456 + imageName: + type: string + example: "docker/image:latest" + environmentVariables: + type: object + additionalProperties: + type: string + example: + POSTGRES_DB: "test_db" + status: + $ref: '#/components/schemas/JobStatus' + standardOutput: + type: string + example: "Job output..." + createdAt: + type: integer + format: int64 + example: 1617181723000 + startedAt: + type: integer + format: int64 + example: 1617182723000 + finishedAt: + type: integer + format: int64 + example: 1617183723000 + ConsumerLongitude: + type: number + format: float + example: -74.0060 + ConsumerLatitude: + type: number + format: float + example: 40.7128 + Co2EquivalentEmissionConsumer: + type: number + format: float + example: 0.5 + WorkerLongitude: + type: number + format: float + example: -122.4194 + WorkerLatitude: + type: number + format: float + example: 37.7749 + Co2EquivalentEmissionWorker: + type: number + format: float + example: 0.8 + estimatedCo2Equivalent: + type: number + format: float + example: 1.3 + + CreateJobRequest: + type: object + required: + - name + - imageName + - ConsumerLongitude + - ConsumerLatitude + properties: + name: + type: string + example: "Data Processing Job" + imageName: + type: string + example: "docker/image:latest" + environmentVariables: + type: object + additionalProperties: + type: string + example: + POSTGRES_DB: "test_db" + ConsumerLongitude: + type: number + format: float + example: -74.0060 + ConsumerLatitude: + type: number + format: float + example: 40.7128 + + UpdateJobRequest: + type: object + properties: + # Fields updatable by consumers (before the job has started) + name: + type: string + example: "Updated Job Name" + imageName: + type: string + example: "docker/image:latest" + environmentVariables: + type: object + additionalProperties: + type: string + example: + NEW_VAR: "new value" + ConsumerLongitude: + type: number + format: float + example: -74.0060 + ConsumerLatitude: + type: number + format: float + example: 40.7128 + # Fields updatable by workers + action: + type: string + enum: [assign] + example: "assign" + status: + $ref: '#/components/schemas/JobStatus' + standardOutput: + type: string + example: "Job output..." + startedAt: + type: integer + format: int64 + example: 1617182723000 + finishedAt: + type: integer + format: int64 + example: 1617183723000 + WorkerLongitude: + type: number + format: float + example: -122.4194 + WorkerLatitude: + type: number + format: float + example: 37.7749 + +security: + - BearerAuth: [] + +paths: + /jobs: + post: + summary: Create a new job (consumers only) + description: > + Consumers can create new jobs by providing necessary details. The consumerId is derived from the JWT token. + operationId: createJob + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateJobRequest' + responses: + '201': + description: Job created successfully + headers: + Location: + schema: + type: string + example: /jobs/jobId123 + description: URL of the created job + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + '400': + description: Invalid job definition + '401': + description: Invalid or missing JWT + '403': + description: Forbidden - only consumers can create jobs + '500': + description: Internal server error + + get: + summary: Retrieve jobs relevant to the requester + description: > + - **Consumers**: Retrieves jobs they have created. + - **Workers**: Retrieves open jobs or jobs assigned to them based on query parameters. + operationId: getJobs + security: + - BearerAuth: [] + parameters: + - in: query + name: status + schema: + $ref: '#/components/schemas/JobStatus' + description: Filter jobs by status (workers can use this to find open jobs) + - in: query + name: assignedToMe + schema: + type: boolean + description: Set to `true` to retrieve jobs assigned to the authenticated worker (workers only) + responses: + '200': + description: A list of jobs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Job' + '401': + description: Invalid or missing JWT + '500': + description: Internal server error + + /job/{id}: + get: + summary: Retrieve details of a specific job + description: > + - **Consumers**: Can access jobs they created. + - **Workers**: Can access jobs assigned to them. + - Enforces least privilege by restricting access based on user role and job ownership. + operationId: getJobDetails + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: Job ID + responses: + '200': + description: Job details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + '401': + description: Invalid or missing JWT + '403': + description: Not authorized to access this job + '404': + description: Job not found + '500': + description: Internal server error + + patch: + summary: Update an existing job + description: > + Updates allowed are based on user role and job status, enforcing least privilege: + - **Consumers**: Can update jobs they created before they have started (status `CREATED`). + - **Workers**: Can assign themselves to open jobs (`status=CREATED`) and update jobs assigned to them. + operationId: updateJob + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: Job ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateJobRequest' + responses: + '200': + description: Job updated successfully + content: + application/json: + schema: + type: object + properties: + id: + type: string + example: jobId123 + updatedFields: + type: object + description: Fields that were updated + '400': + description: Invalid data or action + '401': + description: Invalid or missing JWT + '403': + description: Not authorized to update this job + '404': + description: Job not found + '409': + description: Conflict - job cannot be updated + '500': + description: Internal server error diff --git a/services/job/go.mod b/services/job/go.mod new file mode 100644 index 0000000..c240ef1 --- /dev/null +++ b/services/job/go.mod @@ -0,0 +1,7 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 + +require github.com/google/uuid v1.6.0 diff --git a/services/job/go.sum b/services/job/go.sum new file mode 100644 index 0000000..c9af527 --- /dev/null +++ b/services/job/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/job/main.go b/services/job/main.go new file mode 100644 index 0000000..9b0900d --- /dev/null +++ b/services/job/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + handler_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/adapters/handler-http" + repo "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/adapters/repo-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/job/core" +) + +func main() { + + core := core.NewJobService(repo.NewRepo()) + + srv := &http.Server{Addr: ":8080"} + + h := handler_http.NewHandler(core) + http.Handle("/", h) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + log.Print("listening...") + srv.ListenAndServe() + log.Print("Done") +} diff --git a/services/job/ports/api.go b/services/job/ports/api.go new file mode 100644 index 0000000..65ea60c --- /dev/null +++ b/services/job/ports/api.go @@ -0,0 +1,24 @@ +package ports + +import ( + "context" + "errors" +) + +var ( + ErrJobNotFound = errors.New("job not found") + ErrUnauthorized = errors.New("unauthorized") + ErrJobConflict = errors.New("job conflict") + ErrInvalidJobStatus = errors.New("invalid job status") + ErrInvalidNewStatus = errors.New("invalid new status") +) + +type Api interface { + CreateJob(ctx context.Context, req CreateJobParams, consumerID string) error + //GetJob(ctx context.Context, id string) (Job, error) + UpdateJobForScheduler(ctx context.Context, id string, req UpdateJobForSchedulerParams) error + UpdateJobForWorker(ctx context.Context, id string, req UpdateJobForWorkerParams) error + GetJobsForConsumer(ctx context.Context, consumerID string) ([]Job, error) + GetJobsForWorker(ctx context.Context, workerID string) ([]Job, error) + GetOpenJobs(ctx context.Context) ([]Job, error) +} diff --git a/services/job/ports/model.go b/services/job/ports/model.go new file mode 100644 index 0000000..e075759 --- /dev/null +++ b/services/job/ports/model.go @@ -0,0 +1,67 @@ +package ports + +type Job struct { + Id string + Name string + ConsumerId string + WorkerId string + ImageName string + EnvironmentVariables map[string]string + Status JobStatus + StandardOutput string + + CreatedAt int64 + + StartedAt int64 + FinishedAt int64 + + ConsumerLocation Location + Co2EquivalentEmissionConsumer float64 + + WorkerLocation Location + Co2EquivalentEmissionWorker float64 + + EstimatedCo2Equivalent float64 +} + +type JobStatus string + +const ( + CREATED JobStatus = "CREATED" + PENDING JobStatus = "PENDING" + RUNNING JobStatus = "RUNNING" + FINISHED JobStatus = "FINISHED" + FAILED JobStatus = "FAILED" +) + +type CreateJobParams struct { + Name string + ImageName string + EnvironmentVariables map[string]string + ConsumerLongitude float64 + ConsumerLatitude float64 +} + +type UpdateJobForSchedulerParams struct { + WorkerId string + Co2EquivalentEmissionConsumer float64 + WorkerLocation Location + Co2EquivalentEmissionWorker float64 +} + +type UpdateJobForWorkerParams struct { + Status JobStatus + StandardOutput string + StartedAt int64 + FinishedAt int64 +} + +type User struct { + ID string + Username string +} + +type Location struct { + Longitude float64 + Latitude float64 +} diff --git a/services/job/ports/notifier.go b/services/job/ports/notifier.go new file mode 100644 index 0000000..8e9bc88 --- /dev/null +++ b/services/job/ports/notifier.go @@ -0,0 +1,9 @@ +package ports + +import ( + "context" +) + +type Notifier interface { + EntityChanged(job Job, ctx context.Context) +} diff --git a/services/job/ports/repo.go b/services/job/ports/repo.go new file mode 100644 index 0000000..0b07ce9 --- /dev/null +++ b/services/job/ports/repo.go @@ -0,0 +1,11 @@ +package ports + +import "context" + +type Repo interface { + Store(job Job, ctx context.Context) error + FindById(id string, ctx context.Context) (Job, error) + FindByConsumerId(consumerID string, ctx context.Context) ([]Job, error) + FindByWorkerId(workerID string, ctx context.Context) ([]Job, error) + FindOpenJobs(ctx context.Context) ([]Job, error) +} diff --git a/services/user-management/README.md b/services/user-management/README.md new file mode 100644 index 0000000..d1c3700 --- /dev/null +++ b/services/user-management/README.md @@ -0,0 +1,13 @@ +# Entity Service + +The entity service is an example service that demonstrates the folder structure of a microservice following the ports & adapters architecture. + +> **WARNING** +> The implementation is in an early stage. Many things are still missing. Use with care. + +## Usage + +```bash +curl -X PUT -d '{ "Id": "34", "IntProp" : 23, "StringProp": "test" }' localhost:8080/entity +curl localhost:8080/entity/34 +``` diff --git a/services/user-management/adapters/handler-http/handler.go b/services/user-management/adapters/handler-http/handler.go new file mode 100644 index 0000000..0ce21ea --- /dev/null +++ b/services/user-management/adapters/handler-http/handler.go @@ -0,0 +1,110 @@ +package handler_http + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/user-management/ports" +) + +type Handler struct { + service ports.Api + rtr mux.Router +} + +var _ http.Handler = (*Handler)(nil) + +func NewHandler(service ports.Api) *Handler { + h := Handler{service: service, rtr: *mux.NewRouter()} + + h.rtr.HandleFunc("/register", h.registerUserHandler).Methods("POST") + h.rtr.HandleFunc("/login", h.loginUserHandler).Methods("POST") + h.rtr.HandleFunc("/refresh", h.refreshTokenHandler).Methods("POST") + + return &h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) +} + +// user registration +func (h *Handler) registerUserHandler(w http.ResponseWriter, r *http.Request) { + var reqBody struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "Invalid input", http.StatusBadRequest) + return + } + + // Create a User object and register it + user := ports.User{Username: reqBody.Username, Password: reqBody.Password} + if err := h.service.Register(user, r.Context()); err != nil { + if err == ports.ErrUserAlreadyExists { + http.Error(w, "User already exists", http.StatusConflict) + } else { + http.Error(w, "Failed to register user", http.StatusInternalServerError) + } + return + } + + w.WriteHeader(http.StatusCreated) + w.Write([]byte("User registered successfully")) +} + +// user login +func (h *Handler) loginUserHandler(w http.ResponseWriter, r *http.Request) { + var reqBody struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "Invalid input", http.StatusBadRequest) + return + } + + // Authenticate user and get JWT token + token, err := h.service.Login(reqBody.Username, reqBody.Password, r.Context()) + if err != nil { + if err == ports.ErrLoginFailed { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + } else { + http.Error(w, "Login failed", http.StatusInternalServerError) + } + return + } + + response := map[string]string{"jwtToken": token} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// refreshing JWT tokens +func (h *Handler) refreshTokenHandler(w http.ResponseWriter, r *http.Request) { + var reqBody struct { + OldJwtToken string `json:"oldJwtToken"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "Invalid input", http.StatusBadRequest) + return + } + + // Refresh the token + newToken, err := h.service.RefreshToken(reqBody.OldJwtToken, r.Context()) + if err != nil { + if err == ports.ErrTokenRefreshFailed { + http.Error(w, "Token refresh failed", http.StatusUnauthorized) + } else { + http.Error(w, "Failed to refresh token", http.StatusInternalServerError) + } + return + } + + response := map[string]string{"jwtToken": newToken} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/services/user-management/adapters/repo-in-memory/repo.go b/services/user-management/adapters/repo-in-memory/repo.go new file mode 100644 index 0000000..203df65 --- /dev/null +++ b/services/user-management/adapters/repo-in-memory/repo.go @@ -0,0 +1,41 @@ +package repo_in_memory + +import ( + "context" + "errors" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/user-management/ports" +) + +type Repo struct { + user map[string]ports.User +} + +var ( + _ ports.Repo = (*Repo)(nil) + ErrUserExists = errors.New("user already exists") +) + +func NewRepo() *Repo { + return &Repo{ + user: make(map[string]ports.User), + } +} + +func (r *Repo) Store(user ports.User, ctx context.Context) error { + // Check if user already exists + if _, exists := r.user[user.Username]; exists { + return ErrUserExists + } + + r.user[user.Username] = user + return nil +} + +func (r *Repo) FindByName(name string, ctx context.Context) (ports.User, error) { + user, ok := r.user[name] + if !ok { + return ports.User{}, ports.ErrUserNotFound + } + return user, nil +} diff --git a/services/user-management/core/user-management.go b/services/user-management/core/user-management.go new file mode 100644 index 0000000..b5dfd04 --- /dev/null +++ b/services/user-management/core/user-management.go @@ -0,0 +1,76 @@ +package core + +import ( + "context" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/user-management/ports" +) + +type UserManagementService struct { + repo ports.Repo + notifier ports.Notifier +} + +func NewUserManagementService(repo ports.Repo, notifier ports.Notifier) *UserManagementService { + return &UserManagementService{ + repo: repo, + notifier: notifier, + } +} + +var _ ports.Api = (*UserManagementService)(nil) + +// Register a new user +func (s *UserManagementService) Register(user ports.User, ctx context.Context) error { + existingUser, err := s.repo.FindByName(user.Username, ctx) + if err == nil && existingUser.Username == user.Username { + return ports.ErrUserAlreadyExists + } + + err = s.repo.Store(user, ctx) + if err != nil { + return err + } + s.notifier.UserChanged(user, ctx) + return nil +} + +// Login user +func (s *UserManagementService) Login(username, hashedPassword string, ctx context.Context) (string, error) { + user, err := s.repo.FindByName(username, ctx) + if err != nil || user.Password != hashedPassword { + return "", ports.ErrLoginFailed + } + + token := generateTokenForUser(user) + return token, nil +} + +// Get user by username +func (s *UserManagementService) Get(name string, ctx context.Context) (ports.User, error) { + user, err := s.repo.FindByName(name, ctx) + if err != nil { + return ports.User{}, ports.ErrUserNotFound + } + return user, nil +} + +// RefreshToken generates new token based on old token +func (s *UserManagementService) RefreshToken(oldToken string, ctx context.Context) (string, error) { + user, err := validateTokenAndRetrieveUser(oldToken) + if err != nil { + return "", ports.ErrTokenRefreshFailed + } + + newToken := generateTokenForUser(user) + return newToken, nil +} + +// functions for token generation and validation +func generateTokenForUser(user ports.User) string { + return "generated_token" // Placeholder +} + +func validateTokenAndRetrieveUser(token string) (ports.User, error) { + return ports.User{}, nil // Placeholder +} diff --git a/services/user-management/doc/api.md b/services/user-management/doc/api.md new file mode 100644 index 0000000..ef4b83e --- /dev/null +++ b/services/user-management/doc/api.md @@ -0,0 +1,102 @@ +### Endpoint: User Login + +**Description:** Login with username and password. + +**URL:** `POST /login` + +**Method:** `POST` + +### Request Body + +**Content-Type:** `application/json` + +```json +{ + "username": "string", + "password": "string" +} +``` + +### Success Response + +- **Code:** `200 OK` + - **Content:** + ```json + { + "jwtToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + ``` + +### Error Responses + +- **Code:** `400 BAD REQUEST` + - **Cause:** Missing parameters. + - **Message:** "Your HTTP-Request was missing parameters" + +- **Code:** `401 UNAUTHORIZED` + - **Cause:** Invalid credentials. + - **Message:** "The provided credentials are invalid" + +- **Code:** `403 FORBIDDEN` + - **Cause:** Access denied. + - **Message:** "Access denied" + +- **Code:** `500 INTERNAL SERVER ERROR` + - **Cause:** Server error occurred during user login. + - **Message:** "An error has occurred on the server" + +**Example Call:** +```sh +curl -X POST http://localhost:8080/login \ + -H "Content-Type: application/json" \ + -d '{"username": "exampleUser", "password": "examplePassword"}' +``` + +--- + +### Endpoint: Refresh JWT Token + +**Description:** Refreshes the JWT token before it has expired by providing the old token. + +**URL:** `POST /refresh` + +**Method:** `POST` + +### Request Body + +**Content-Type:** `application/json` + +```json +{ + "oldJwtToken": "string" +} +``` + +### Success Response + +- **Code:** `200 OK` + - **Content:** + ```json + { + "jwtToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + ``` + +### Error Responses + +- **Code:** `401 UNAUTHORIZED` + - **Cause:** Invalid token. + - **Message:** "The provided token is invalid" + +- **Code:** `498 INVALID TOKEN` + - **Cause:** Token expired. + - **Message:** "The provided token is expired" + +**Example Call:** +```sh +curl -X POST http://localhost:8080/refresh \ + -H "Content-Type: application/json" \ + -d '{"oldJwtToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}' +``` + +--- \ No newline at end of file diff --git a/services/user-management/doc/api.yaml b/services/user-management/doc/api.yaml new file mode 100644 index 0000000..668e588 --- /dev/null +++ b/services/user-management/doc/api.yaml @@ -0,0 +1,163 @@ +openapi: 3.0.3 +info: + title: User Management API + version: '1.0' + description: API for managing Users authenticated via JWT Tokens. +servers: + - url: http://localhost:8080 +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + LoginRequest: + type: object + properties: + username: + type: string + password: + type: string + required: + - username + - password + RegisterRequest: + type: object + properties: + username: + type: string + password: + type: string + required: + - username + - password + +paths: + /login: + post: + tags: + - User_Management + summary: User login + description: Login with username and password + requestBody: + description: Login with username and password + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + required: true + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + jwtToken: + type: string + description: "The JWT token" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + '400': + description: Bad request + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: "Bad request" + example: "Your HTTP was missing parameters" + '401': + description: Unauthorized + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: "Unauthorized" + example: "The provided credentials are invalid" + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: "Forbidden" + example: "Access denied" + '500': + description: Server error occurred during user login. + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: "Server error" + example: "An error has occured on the server" + + + /refresh: + post: + tags: + - User_Management + summary: Refresh JWT Token + description: Refreshes the JWT token before it has expired by providing the old token. + requestBody: + description: Old JWT token for refreshing + content: + application/json: + schema: + type: object + properties: + oldJwtToken: + type: string + description: "The old JWT token that needs to be refreshed" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + required: true + responses: + '200': + description: Token successfully refreshed + content: + application/json: + schema: + type: object + properties: + jwtToken: + type: string + description: "The refreshed JWT token" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + '401': + description: Unauthorized + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Error message if request is not authenticated + example: "The provided token is invalid" + '498': + description: Expired token + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Error message if token is expired + example: "The provided token is expired" + security: + - BearerAuth: [] + + diff --git a/services/user-management/go.mod b/services/user-management/go.mod new file mode 100644 index 0000000..7489755 --- /dev/null +++ b/services/user-management/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/user-management + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 diff --git a/services/user-management/go.sum b/services/user-management/go.sum new file mode 100644 index 0000000..7128337 --- /dev/null +++ b/services/user-management/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/user-management/main.go b/services/user-management/main.go new file mode 100644 index 0000000..9e2b4dc --- /dev/null +++ b/services/user-management/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + handler_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/user-management/adapters/handler-http" + repo "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/user-management/adapters/repo-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/user-management/core" +) + +func main() { + + core := core.NewUserManagementService(repo.NewRepo(), nil) + + srv := &http.Server{Addr: ":8080"} + + h := handler_http.NewHandler(core) + http.Handle("/", h) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + log.Print("listening...") + srv.ListenAndServe() + log.Print("Done") +} diff --git a/services/user-management/ports/api.go b/services/user-management/ports/api.go new file mode 100644 index 0000000..b971aea --- /dev/null +++ b/services/user-management/ports/api.go @@ -0,0 +1,18 @@ +package ports + +import ( + "context" + "errors" +) + +var ErrUserNotFound = errors.New("user not found") +var ErrLoginFailed = errors.New("login failed") +var ErrUserAlreadyExists = errors.New("user already exists") +var ErrTokenRefreshFailed = errors.New("token refresh failed") + +type Api interface { + Register(user User, ctx context.Context) error + Login(username, password string, ctx context.Context) (string, error) + Get(name string, ctx context.Context) (User, error) + RefreshToken(oldToken string, ctx context.Context) (string, error) +} diff --git a/services/user-management/ports/model.go b/services/user-management/ports/model.go new file mode 100644 index 0000000..79261d2 --- /dev/null +++ b/services/user-management/ports/model.go @@ -0,0 +1,6 @@ +package ports + +type User struct { + Username string + Password string +} diff --git a/services/user-management/ports/notifier.go b/services/user-management/ports/notifier.go new file mode 100644 index 0000000..e22abc9 --- /dev/null +++ b/services/user-management/ports/notifier.go @@ -0,0 +1,9 @@ +package ports + +import ( + "context" +) + +type Notifier interface { + UserChanged(user User, ctx context.Context) +} diff --git a/services/user-management/ports/repo.go b/services/user-management/ports/repo.go new file mode 100644 index 0000000..1761f1b --- /dev/null +++ b/services/user-management/ports/repo.go @@ -0,0 +1,10 @@ +package ports + +import ( + "context" +) + +type Repo interface { + Store(user User, ctx context.Context) error + FindByName(name string, ctx context.Context) (User, error) +} diff --git a/services/worker-gateway/adapters/handler-http/handler.go b/services/worker-gateway/adapters/handler-http/handler.go new file mode 100644 index 0000000..c1e1717 --- /dev/null +++ b/services/worker-gateway/adapters/handler-http/handler.go @@ -0,0 +1,61 @@ +package handler_http + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/gorilla/mux" +) + +type Handler struct { + rtr mux.Router +} + +var _ http.Handler = (*Handler)(nil) + +func NewHandler(prefix string) *Handler { + log.Printf("Worker-Gateway: Initializing new handler with prefix: %s", prefix) + + // TODO: Get the target urls from config + workerProxy := createProxy("http://localhost:8081") + jobProxy := createProxy("http://localhost:8082") + userManagementProxy := createProxy("http://localhost:8083") + + h := Handler{rtr: *mux.NewRouter().PathPrefix(prefix).Subrouter()} + h.rtr.PathPrefix("/workers").HandlerFunc(handlerForProxy(workerProxy)) + h.rtr.PathPrefix("/jobs").HandlerFunc(handlerForProxy(jobProxy)) + h.rtr.PathPrefix("/login").HandlerFunc(handlerForProxy(userManagementProxy)) + h.rtr.PathPrefix("/refresh").HandlerFunc(handlerForProxy(userManagementProxy)) + + log.Printf("Routes registered successfully") + + return &h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) +} + +func createProxy(target string) *httputil.ReverseProxy { + log.Printf("Creating proxy for target URL: %s", target) + + url, err := url.Parse(target) + if err != nil { + log.Fatalf("Failed to parse upstream url %v: %v", target, err) + } + + log.Printf("Successfully parsed URL: %s", url.String()) + + log.Printf("Proxy created successfully for target: %s", target) + return httputil.NewSingleHostReverseProxy(url) +} + +func handlerForProxy(p *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + p.ServeHTTP(w, r) + + log.Printf("Request for %s completed", r.URL.Path) + } +} diff --git a/services/worker-gateway/doc/api.yaml b/services/worker-gateway/doc/api.yaml new file mode 100644 index 0000000..578aebf --- /dev/null +++ b/services/worker-gateway/doc/api.yaml @@ -0,0 +1,296 @@ +openapi: 3.0.0 +info: + title: Worker Gateway + description: REST API documentation for the Worker Gateway. + version: 1.0.0 + +tags: + - name: gateway + description: Operations related to the Worker Gateway. + +paths: + /jobs: + get: + summary: Returns a job for the worker with id. + tags: + - gateway + parameters: + - name: workerId + in: query + required: true + schema: + type: string + description: The id of the worker for which jobs should be returned. + responses: + '200': + $ref: '#/components/responses/returnJob' + '204': + $ref: '#/components/responses/noJobReturned' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/accessForbidden' + '500': + description: Internal Server Error + /jobs/{id}: + patch: + summary: Patches a job for the worker with id. + tags: + - gateway + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The id of the job which should be patched. + - name: workerId + in: query + required: true + schema: + type: string + description: The id of the worker that wants to patch a job. + requestBody: + description: Optional description in *Markdown* + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/patchedJob" + responses: + '204': + description: Job was patched + '400': + description: Invalid parameter or changed immutable parameter. + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/accessForbidden' + '500': + description: Internal Server Error. + /workers: + post: + summary: Registers a new worker. + tags: + - gateway + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/worker' + responses: + '201': + description: New worker successfully registered. + '400': + description: Request body contains invalid data. + '409': + description: Worker already exists. + '500': + description: Internal server error. + + /workers/{id}: + patch: + summary: Updates existing worker. + tags: + - gateway + parameters: + - name: id + in: path + required: true + description: The ID of the worker to update. + schema: + type: string + example: dad91cf8-6011-4155-81d3-6c4ce64abb6a + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: The current status of the worker indicating whether it can take more jobs. + enum: + - REQUIRES_WORK # Worker can accept more jobs + - EXHAUSTED # Worker has reached its capacity + location: + type: string + description: The current location of the worker. + example: "Germany" + responses: + '200': + description: Worker successfully updated. + '400': + description: Request body contains invalid data. + '403': + description: Access forbidden. + '500': + description: Internal server error. + delete: + summary: Unregister worker. + tags: + - gateway + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The ID of the worker to delete. + responses: + '204': + description: Worker successfully deleted. + '404': + description: Worker not found. + '403': + description: Access forbidden. + '500': + description: Internal server error. + +components: + schemas: + environmentVariables: + type: object + additionalProperties: + type: string + description: Environment variables for the compute job. + example: + env1: value + env2: value + imageName: + type: string + description: The name of the DockerHub image. + example: DockerHubImageName + job: + type: object + properties: + id: + type: string + description: Unique ID of the compute job. + example: eb12c557-cbc8-45be-b7ec-9b936730171e + name: + $ref: '#/components/schemas/jobName' + consumerId: + type: string + description: Id of the creator of this job. It is defined at job creation. + example: d2d171f4-f99f-4576-816c-4e58c004585b + workerId: + type: string + description: Id of the worker that the job runs on. It is defined when the job is assigned to a worker. + example: 043e6076-d014-4abe-a383-a69e709b9169 + imageName: + $ref: '#/components/schemas/imageName' + environmentVariables: + $ref: '#/components/schemas/environmentVariables' + status: + type: string # or define the enum here as well? + description: Current status of the compute job. + example: RUNNING + standardOutput: + type: string + description: Output of the compute job after completion. + example: An output. + createdAt: + type: integer + format: int64 + description: Unix timestamp in milliseconds from compute job creation. + example: 1721506660000 + startedAt: + type: integer + format: int64 + description: Unix timestamp in milliseconds from when the compute job was started. + example: 1721506660000 + finishedAt: + type: integer + format: int64 + description: Unix timestamp in milliseconds from when the compute job finished. + example: 1721506660000 + consumerLocation: + type: string + description: Location of the consumer that created the compute job. + example: ger + Co2EquivalentEmissionConsumer: + type: number + format: double + description: CO2 equivalent value of the consumer`s location. + example: 386.232323 + workerLocation: + type: string + description: Location of the worker that the compute job runs on. + example: fra + Co2EquivalentEmissionWorker: + type: number + format: double + description: CO2 equivalent value of the worker`s location. + example: 21.43432 + estimatedCo2Equivalent: + type: number + format: double + description: Final CO2 equivalent of the finished job. + example: 8.6563 + jobName: + type: string + description: A descriptive name for the compute job. + example: MyJob + patchedJob: + type: object + properties: + status: + type: string + description: status of the job + example: RUNNING + standardOutput: + type: string + description: Standard out of the Job + worker: + type: object + properties: + id: + type: string + description: The unique identifier of the worker. + example: dad91cf8-6011-4155-81d3-6c4ce64abb6a + status: + type: string + description: The current status of the worker indicating whether it can take more jobs. + enum: + - REQUIRES_WORK # Worker can accept more jobs + - EXHAUSTED # Worker has reached its capacity + location: + type: string + description: The current location of the worker. + example: "Germany" + required: + - id + - status + - location + + securitySchemes: + bearerAuth: # arbitrary name for the security scheme + type: http + scheme: bearer + bearerFormat: JWT + + responses: + UnauthorizedError: + description: Authentication information is missing or invalid. + returnJob: + description: A JSON representation of the job. + content: + application/json: + schema: + $ref: '#/components/schemas/job' + returnJobPatched: + description: A JSON representation of the Patched job. + content: + application/json: + schema: + $ref: '#/components/schemas/job' + noJobReturned: + description: No job for this worker available. + accessForbidden: + description: Cannot access jobs of other workers. + +security: + - bearerAuth: [] \ No newline at end of file diff --git a/services/worker-gateway/go.mod b/services/worker-gateway/go.mod new file mode 100644 index 0000000..f7d9987 --- /dev/null +++ b/services/worker-gateway/go.mod @@ -0,0 +1,5 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-gateway + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 diff --git a/services/worker-gateway/go.sum b/services/worker-gateway/go.sum new file mode 100644 index 0000000..7128337 --- /dev/null +++ b/services/worker-gateway/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/worker-gateway/main.go b/services/worker-gateway/main.go new file mode 100644 index 0000000..a983bf1 --- /dev/null +++ b/services/worker-gateway/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + handler_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-gateway/adapters/handler-http" +) + +func main() { + + h := handler_http.NewHandler("/api/v1") + http.Handle("/", h) + + srv := &http.Server{Addr: ":8080"} + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + log.Print("listening...") + srv.ListenAndServe() + log.Print("Done") +} diff --git a/services/worker-registry/adapters/handler-http/handler.go b/services/worker-registry/adapters/handler-http/handler.go new file mode 100644 index 0000000..1ade0bf --- /dev/null +++ b/services/worker-registry/adapters/handler-http/handler.go @@ -0,0 +1,133 @@ +package handler_http + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/gorilla/mux" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/helpers" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/ports" +) + +type Handler struct { + service ports.Api + rtr mux.Router +} + +var _ http.Handler = (*Handler)(nil) + +func NewHandler(service ports.Api, prefix string) *Handler { + + log.Printf("Worker-Registry: Initializing new Handler with prefix: %s", prefix) + + h := Handler{service: service, rtr: *mux.NewRouter().PathPrefix(prefix).Subrouter()} + h.rtr.HandleFunc("/workers/{id}", h.handleWorkerGet).Methods("GET") + h.rtr.HandleFunc("/workers", h.handleWorkersGet).Methods("GET") + h.rtr.HandleFunc("/workers", h.handleWorkersPost).Methods("POST") + h.rtr.HandleFunc("/workers/{id}", h.handleWorkersPatch).Methods("PATCH") + h.rtr.HandleFunc("/workers/{id}", h.handleWorkersDelete).Methods("DELETE") + + return &h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.rtr.ServeHTTP(w, r) +} + +func (h *Handler) handleWorkerGet(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + worker, err := h.service.GetWorker(vars["id"], r.Context()) + + log.Printf("Handling GET request for worker with ID: %s", worker.Id) + + if err != nil { + log.Printf("Worker with ID %s not found: %v", worker.Id, err) + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(worker) +} + +func (h *Handler) handleWorkersGet(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + status := ports.WorkerStatus(query.Get("status")) + + log.Printf("Handling GET request for workers with status: %s", status) + + workers, err := h.service.GetWorkers(status, r.Context()) + if err != nil { + log.Printf("Failed to retrieve workers with status %s: %v", status, err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.Printf("Successfully retrieved %d workers with status: %s", len(workers), status) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(workers) +} + +func (h *Handler) handleWorkersPost(w http.ResponseWriter, r *http.Request) { + log.Printf("Handling POST request to register a new worker") + + var worker ports.Worker + if err := json.NewDecoder(r.Body).Decode(&worker); err != nil { + log.Printf("Failed to decode worker from request body: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.Printf("Registering worker with ID: %s", worker.Id) + + if err := h.service.RegisterWorker(worker, r.Context()); err != nil { + log.Printf("Failed to register worker with ID %s: %v", worker.Id, err) + http.Error(w, err.Error(), helpers.ErrorToHttpStatus(err)) + return + } + + log.Printf("Successfully registered worker with ID: %s", worker.Id) + w.WriteHeader(http.StatusCreated) +} + +func (h *Handler) handleWorkersPatch(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + log.Printf("Handling PATCH request to update worker with ID: %s", vars["id"]) + + var patch ports.WorkerPatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + log.Printf("Failed to decode patch data for worker with ID %s: %v", vars["id"], err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.Printf("Updating worker with ID: %s to new status: %s", vars["id"], patch.Status) + + if err := h.service.UpdateWorker(vars["id"], patch.Status, r.Context()); err != nil { + log.Printf("Failed to update worker with ID %s: %v", vars["id"], err) + http.Error(w, err.Error(), helpers.ErrorToHttpStatus(err)) + return + } + + log.Printf("Successfully updated worker with ID: %s to status: %s", vars["id"], patch.Status) + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) handleWorkersDelete(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + log.Printf("Handling DELETE request for worker with ID: %s", vars["id"]) + + if err := h.service.UnregisterWorker(vars["id"], r.Context()); err != nil { + log.Printf("Failed to unregister worker with ID %s: %v", vars["id"], err) + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + log.Printf("Successfully unregistered worker with ID: %s", vars["id"]) + w.WriteHeader(http.StatusNoContent) +} diff --git a/services/worker-registry/adapters/handler-http/handler_test.go b/services/worker-registry/adapters/handler-http/handler_test.go new file mode 100644 index 0000000..bca340e --- /dev/null +++ b/services/worker-registry/adapters/handler-http/handler_test.go @@ -0,0 +1 @@ +package handler_http_test diff --git a/services/worker-registry/adapters/repo-in-memory/repo.go b/services/worker-registry/adapters/repo-in-memory/repo.go new file mode 100644 index 0000000..0574175 --- /dev/null +++ b/services/worker-registry/adapters/repo-in-memory/repo.go @@ -0,0 +1,81 @@ +package repo_in_memory + +import ( + "context" + "log" + "sync" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/ports" +) + +type Repo struct { + workers map[string]ports.Worker + lock sync.RWMutex +} + +var _ ports.Repo = (*Repo)(nil) + +func NewRepo() *Repo { + log.Printf("Worker-Registry: Initializing new in-memory repository") + + return &Repo{ + workers: make(map[string]ports.Worker), + } +} + +func (r *Repo) Store(worker ports.Worker, ctx context.Context) { + r.lock.Lock() + defer r.lock.Unlock() + r.workers[worker.Id] = worker + + log.Printf("Worker with ID=%s has been stored", worker.Id) +} + +func (r *Repo) FindById(id string, ctx context.Context) (ports.Worker, error) { + r.lock.RLock() + defer r.lock.RUnlock() + worker, ok := r.workers[id] + + if !ok { + log.Printf("Worker with ID=%s not found", worker.Id) + return ports.Worker{}, ports.ErrWorkerNotFound + } + log.Printf("Worker with ID=%s has been found", worker.Id) + + return worker, nil +} + +func (r *Repo) GetAll() []ports.Worker { + r.lock.RLock() + defer r.lock.RUnlock() + workers := []ports.Worker{} + + for _, worker := range r.workers { + workers = append(workers, worker) + } + log.Printf("Get all Workers. Total Workers: %d", len(r.workers)) + + return workers +} + +func (r *Repo) GetFiltered(filter func(ports.Worker) bool) []ports.Worker { + r.lock.RLock() + defer r.lock.RUnlock() + workers := []ports.Worker{} + + for _, worker := range r.workers { + if filter(worker) { + workers = append(workers, worker) + } + } + log.Printf("Get filtered Workers. Total filtered Workers: %d", len(r.workers)) + + return workers +} + +func (r *Repo) Delete(id string) { + r.lock.Lock() + defer r.lock.Unlock() + delete(r.workers, id) + log.Printf("Worker with ID=%s has been deleted", id) +} diff --git a/services/worker-registry/adapters/repo-in-memory/repo_test.go b/services/worker-registry/adapters/repo-in-memory/repo_test.go new file mode 100644 index 0000000..7001754 --- /dev/null +++ b/services/worker-registry/adapters/repo-in-memory/repo_test.go @@ -0,0 +1 @@ +package repo_in_memory_test diff --git a/services/worker-registry/core/worker_registry.go b/services/worker-registry/core/worker_registry.go new file mode 100644 index 0000000..86ade1a --- /dev/null +++ b/services/worker-registry/core/worker_registry.go @@ -0,0 +1,119 @@ +package core + +import ( + "context" + "log" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/helpers" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/ports" +) + +type WorkerRegistryService struct { + repo ports.Repo +} + +func NewWorkerRegistryService(repo ports.Repo) *WorkerRegistryService { + log.Print("Worker-Registry: Initializing new Worker-Registry-Service") + + return &WorkerRegistryService{ + repo: repo, + } +} + +var _ ports.Api = (*WorkerRegistryService)(nil) + +func (s *WorkerRegistryService) GetWorker(id string, ctx context.Context) (ports.Worker, error) { + worker, err := s.repo.FindById(id, ctx) + + log.Printf("Attempting to fetch worker with ID: %s", id) + + if err != nil { + log.Printf("Error retrieving worker with ID %s: %v", id, err) + + return ports.Worker{}, err + } + log.Printf("Successfully retrieved worker with ID: %s", worker.Id) + + return worker, nil +} + +func (s *WorkerRegistryService) GetWorkers(status ports.WorkerStatus, ctx context.Context) ([]ports.Worker, error) { + log.Printf("Attempting to fetch workers with status: %s", status) + + if status == "" { + log.Printf("No status. Retrieved all workers") + + return s.repo.GetAll(), nil + } + + if !helpers.IsValidWorkerStatus(status) { + log.Printf("Invalid status. Retrieved no workers") + + return nil, ports.ErrWorkerStatusInvalid + } + + workers := s.repo.GetFiltered(func(worker ports.Worker) bool { + return worker.Status == status + }) + log.Printf("Successfully retrieved worker with status: %s", status) + + return workers, nil +} + +func (s *WorkerRegistryService) RegisterWorker(worker ports.Worker, ctx context.Context) error { + log.Printf("Attempting to register worker with ID: %s", worker.Id) + + if !helpers.IsValidWorkerStatus(worker.Status) { + log.Printf("Invalid worker status for worker ID: %s", worker.Id) + + return ports.ErrWorkerStatusInvalid + } + + if _, err := s.repo.FindById(worker.Id, ctx); err == nil { + log.Printf("Worker with ID %s already registered", worker.Id) + + return ports.ErrWorkerAlreadyRegistered + } + log.Printf("Worker with ID %s successfully registered", worker.Id) + + s.repo.Store(worker, ctx) + return nil +} + +func (s *WorkerRegistryService) UpdateWorker(id string, status ports.WorkerStatus, ctx context.Context) error { + log.Printf("Attempting to update status of worker with ID: %s", id) + + if !helpers.IsValidWorkerStatus(status) { + log.Printf("Invalid worker status for worker ID: %s", id) + + return ports.ErrWorkerStatusInvalid + } + + worker, err := s.repo.FindById(id, ctx) + if err != nil { + log.Printf("Worker with ID %s not found: %v", id, err) + + return ports.ErrWorkerNotFound + } + + worker.Status = status + s.repo.Store(worker, ctx) + log.Printf("Worker with ID %s successfully updated to status: %s", id, status) + + return nil +} + +func (s *WorkerRegistryService) UnregisterWorker(id string, ctx context.Context) error { + log.Printf("Attempting to unregister worker with ID: %s", id) + + if _, err := s.repo.FindById(id, ctx); err != nil { + log.Printf("Worker with ID %s not found: %v", id, err) + + return ports.ErrWorkerNotFound + } + + s.repo.Delete(id) + log.Printf("Worker with ID %s successfully unregistered.", id) + + return nil +} diff --git a/services/worker-registry/core/worker_registry_test.go b/services/worker-registry/core/worker_registry_test.go new file mode 100644 index 0000000..91870c6 --- /dev/null +++ b/services/worker-registry/core/worker_registry_test.go @@ -0,0 +1,343 @@ +package core_test + +import ( + "context" + "reflect" + "testing" + + repo_in_memory "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/adapters/repo-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/core" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/ports" +) + +func TestWorkerRegistryService_GetWorker(t *testing.T) { + ctx := context.Background() + + repo := repo_in_memory.NewRepo() + + repo.Store(ports.Worker{ + Id: "uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{ + Longitude: 2.5, + Latitude: 3.5}, + }, ctx) + + type args struct { + id string + ctx context.Context + } + + tests := []struct { + name string + repo ports.Repo + args args + want ports.Worker + wantErr bool + }{ + { + name: "Get existing worker", + repo: repo, + args: args{"uuid", ctx}, + want: ports.Worker{ + Id: "uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{ + Longitude: 2.5, + Latitude: 3.5}, + }, + wantErr: false, + }, + { + name: "Get not existing worker", + repo: repo, + args: args{"invalid", ctx}, + want: ports.Worker{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := core.NewWorkerRegistryService(tt.repo) + got, err := s.GetWorker(tt.args.id, tt.args.ctx) + + if (err != nil) != tt.wantErr { + t.Errorf("WorkerRegistryService.GetWorker() error = %v, wantErr %v", err, tt.wantErr) + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("WorkerRegistryService.GetWorker() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWorkerRegistryService_GetWorkers(t *testing.T) { + ctx := context.Background() + + repo := repo_in_memory.NewRepo() + repoEmpty := repo_in_memory.NewRepo() + + repo.Store(ports.Worker{ + Id: "w1-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.3, Latitude: 3.3}, + }, ctx) + + repo.Store(ports.Worker{ + Id: "w2-uuid", + Status: ports.EXHAUSTED, + Location: ports.Location{Longitude: 2.7, Latitude: 8.8}, + }, ctx) + + type args struct { + status ports.WorkerStatus + ctx context.Context + } + + tests := []struct { + name string + repo ports.Repo + args args + want []ports.Worker + wantErr bool + }{ + { + name: "Get all workers from not empty repo", + repo: repo, + args: args{"", ctx}, + want: []ports.Worker{ + { + Id: "w1-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.3, Latitude: 3.3}, + }, + { + Id: "w2-uuid", + Status: ports.EXHAUSTED, + Location: ports.Location{Longitude: 2.7, Latitude: 8.8}, + }, + }, + wantErr: false, + }, + { + name: "Get all workers from empty repo", + repo: repoEmpty, + args: args{"", ctx}, + want: []ports.Worker{}, + wantErr: false, + }, + { + name: "Get workers with status REQUIRES_WORK", + repo: repo, + args: args{ports.REQUIRES_WORK, ctx}, + want: []ports.Worker{ + { + Id: "w1-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.3, Latitude: 3.3}, + }, + }, + wantErr: false, + }, + { + name: "Get workers with invaid status", + repo: repo, + args: args{"invalid", ctx}, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := core.NewWorkerRegistryService(tt.repo) + got, err := s.GetWorkers(tt.args.status, tt.args.ctx) + + if (err != nil) != tt.wantErr { + t.Errorf("WorkerRegistryService.GetWorkers(%v) error = %v, wantErr %v", tt.args.status, err, tt.wantErr) + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("WorkerRegistryService.GetWorkers(%v) = %v, want %v", tt.args.status, got, tt.want) + } + }) + } +} + +func TestWorkerRegistryService_RegisterWorker(t *testing.T) { + ctx := context.Background() + + repo := repo_in_memory.NewRepo() + + repo.Store(ports.Worker{ + Id: "w1-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.3, Latitude: 3.3}, + }, ctx) + + type args struct { + worker ports.Worker + ctx context.Context + } + + tests := []struct { + name string + repo ports.Repo + args args + wantErr bool + }{ + { + name: "Register worker successfully", + repo: repo, + args: args{ports.Worker{ + Id: "w2-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.7, Latitude: 4.2}, + }, ctx}, + wantErr: false, + }, + { + name: "Register worker with invalid status", + repo: repo, + args: args{ports.Worker{ + Id: "w2-uuid", + Status: "invalid", + Location: ports.Location{Longitude: 1.7, Latitude: 4.2}, + }, ctx}, + wantErr: true, + }, + { + name: "Register already existing worker", + repo: repo, + args: args{ports.Worker{ + Id: "w1-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.7, Latitude: 4.2}, + }, ctx}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := core.NewWorkerRegistryService(tt.repo) + err := s.RegisterWorker(tt.args.worker, tt.args.ctx) + + if (err != nil) != tt.wantErr { + t.Errorf("WorkerRegistryService.RegisterWorker() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestWorkerRegistryService_UpdateWorker(t *testing.T) { + ctx := context.Background() + + repo := repo_in_memory.NewRepo() + + repo.Store(ports.Worker{ + Id: "w1-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.3, Latitude: 3.3}, + }, ctx) + + type args struct { + id string + status ports.WorkerStatus + ctx context.Context + } + + tests := []struct { + name string + repo ports.Repo + args args + wantErr bool + }{ + { + name: "Update worker successfully", + repo: repo, + args: args{"w1-uuid", ports.EXHAUSTED, ctx}, + wantErr: false, + }, + { + name: "Update worker with invalid status", + repo: repo, + args: args{"w1-uuid", "invalid", ctx}, + wantErr: true, + }, + { + name: "Update not existing worker", + repo: repo, + args: args{"invalid", ports.EXHAUSTED, ctx}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := core.NewWorkerRegistryService(tt.repo) + err := s.UpdateWorker(tt.args.id, tt.args.status, tt.args.ctx) + + if (err != nil) != tt.wantErr { + t.Errorf("WorkerRegistryService.UpdateWorker(%s, %v) error = %v, wantErr %v", + tt.args.id, + tt.args.status, + err, + tt.wantErr) + } + }) + } +} + +func TestWorkerRegistryService_UnregisterWorker(t *testing.T) { + ctx := context.Background() + + repo := repo_in_memory.NewRepo() + + repo.Store(ports.Worker{ + Id: "w1-uuid", + Status: ports.REQUIRES_WORK, + Location: ports.Location{Longitude: 1.3, Latitude: 3.3}, + }, ctx) + + type args struct { + id string + ctx context.Context + } + + tests := []struct { + name string + repo ports.Repo + args args + wantErr bool + }{ + { + name: "Unregister worker successfully", + repo: repo, + args: args{"w1-uuid", ctx}, + wantErr: false, + }, + { + name: "Unregister not existing worker", + repo: repo, + args: args{"invalid", ctx}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := core.NewWorkerRegistryService(tt.repo) + err := s.UnregisterWorker(tt.args.id, tt.args.ctx) + + if (err != nil) != tt.wantErr { + t.Errorf("WorkerRegistryService.UnregisterWorker(%s) error = %v, wantErr %v", + tt.args.id, + err, + tt.wantErr) + } + }) + } +} diff --git a/services/worker-registry/doc/api.yaml b/services/worker-registry/doc/api.yaml new file mode 100644 index 0000000..85069e5 --- /dev/null +++ b/services/worker-registry/doc/api.yaml @@ -0,0 +1,190 @@ +openapi: 3.0.0 +info: + title: Worker Repository + description: REST API documentation for the Worker Repository Service. + version: 1.0.0 + +tags: + - name: workers + description: Operations related to workers. + +paths: + /workers: + get: + summary: Returns a list of workers. + tags: + - workers + parameters: + - name: status + in: query + required: false + description: The status to filter workers by. + schema: + type: string + enum: + - EXHAUSTED + - REQUIRES_WORK + responses: + '200': + description: A JSON array of workers. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/worker' + '400': + description: Invalid query parameters. + '500': + description: Internal server error. + post: + summary: Registers a new worker. + tags: + - workers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/worker' + responses: + '201': + description: New worker successfully registered. + '400': + description: Request body contains invalid data. + '409': + description: Worker already exists. + '500': + description: Internal server error. + + /workers/{id}: + get: + summary: Get worker by ID. + tags: + - workers + parameters: + - name: id + in: path + required: true + description: The ID of the worker to retrive. + schema: + type: string + example: dad91cf8-6011-4155-81d3-6c4ce64abb6a + responses: + '200': + description: Worker retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/worker' + '403': + description: Access forbidden. + '500': + description: Internal server error. + patch: + summary: Updates existing worker. + tags: + - workers + parameters: + - name: id + in: path + required: true + description: The ID of the worker to update. + schema: + type: string + example: dad91cf8-6011-4155-81d3-6c4ce64abb6a + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: The current status of the worker indicating whether it can take more jobs. + enum: + - REQUIRES_WORK # Worker can accept more jobs + - EXHAUSTED # Worker has reached its capacity + responses: + '200': + description: Worker successfully updated. + '400': + description: Request body contains invalid data. + '403': + description: Access forbidden. + '500': + description: Internal server error. + delete: + summary: Unregister worker. + tags: + - workers + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The ID of the worker to delete. + responses: + '204': + description: Worker successfully deleted. + '404': + description: Worker not found. + '403': + description: Access forbidden. + '500': + description: Internal server error. + +components: + securitySchemes: + bearerAuth: # arbitrary name for the security scheme + type: http + scheme: bearer + bearerFormat: JWT + schemas: + worker: + type: object + properties: + id: + type: string + description: The unique identifier of the worker. + example: dad91cf8-6011-4155-81d3-6c4ce64abb6a + status: + $ref: '#/components/schemas/workerStatus' + location: + $ref: '#/components/schemas/location' + required: + - id + - status + - location + location: + type: object + properties: + longitude: + type: number + format: float64 + description: The longitude of the worker + example: 50 + latitude: + type: number + format: float64 + description: The latitude of the worker. + example: 100 + required: + - longitude + - latitude + workerPatch: + type: object + properties: + status: + $ref: '#/components/schemas/workerStatus' + workerStatus: + type: string + description: The worker status. + enum: + - REQUIRES_WORK # Worker can accept more jobs + - EXHAUSTED # Worker has reached its capacity + +security: + - bearerAuth: [] diff --git a/services/worker-registry/go.mod b/services/worker-registry/go.mod new file mode 100644 index 0000000..d1b15d5 --- /dev/null +++ b/services/worker-registry/go.mod @@ -0,0 +1,7 @@ +module gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry + +go 1.23.1 + +require github.com/gorilla/mux v1.8.1 +require gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/logging v0.0.0 +replace gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/logging => ../../pkg/logging diff --git a/services/worker-registry/go.sum b/services/worker-registry/go.sum new file mode 100644 index 0000000..7128337 --- /dev/null +++ b/services/worker-registry/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/services/worker-registry/helpers/helpers.go b/services/worker-registry/helpers/helpers.go new file mode 100644 index 0000000..05f469d --- /dev/null +++ b/services/worker-registry/helpers/helpers.go @@ -0,0 +1,35 @@ +package helpers + +import ( + "errors" + "log" + "net/http" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/ports" +) + +func IsValidWorkerStatus(status ports.WorkerStatus) bool { + switch ports.WorkerStatus(status) { + case ports.REQUIRES_WORK, ports.EXHAUSTED: + log.Printf("WorkerStatus: %s is valid", status) + + return true + default: + log.Printf("WorkerStatus: %s is invalid", status) + + return false + } +} + +func ErrorToHttpStatus(err error) int { + switch { + case errors.Is(err, ports.ErrWorkerStatusInvalid): + return http.StatusBadRequest + case errors.Is(err, ports.ErrWorkerAlreadyRegistered): + return http.StatusConflict + case errors.Is(err, ports.ErrWorkerNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} diff --git a/services/worker-registry/main.go b/services/worker-registry/main.go new file mode 100644 index 0000000..c05d64f --- /dev/null +++ b/services/worker-registry/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/pkg/logging" + handler_http "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/adapters/handler-http" + repo "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/adapters/repo-in-memory" + "gitty.informatik.hs-mannheim.de/steger/cmg-ws202425/services/worker-registry/core" +) + +func main() { + logging.Init("worker-registry") + + core := core.NewWorkerRegistryService(repo.NewRepo()) + + srv := &http.Server{Addr: ":8081"} + + h := handler_http.NewHandler(core, "/api/v1") + http.Handle("/", h) + + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + slog.Info("The service is shutting down...") + srv.Shutdown(context.Background()) + }() + + slog.Info("listening...") + srv.ListenAndServe() + slog.Info("Done") +} diff --git a/services/worker-registry/ports/api.go b/services/worker-registry/ports/api.go new file mode 100644 index 0000000..0bc2356 --- /dev/null +++ b/services/worker-registry/ports/api.go @@ -0,0 +1,20 @@ +package ports + +import ( + "context" + "errors" +) + +var ( + ErrWorkerNotFound = errors.New("worker not found") + ErrWorkerAlreadyRegistered = errors.New("worker already registered") + ErrWorkerStatusInvalid = errors.New("invalid worker status") +) + +type Api interface { + GetWorker(id string, ctx context.Context) (Worker, error) + GetWorkers(status WorkerStatus, ctx context.Context) ([]Worker, error) + RegisterWorker(worker Worker, ctx context.Context) error + UpdateWorker(id string, status WorkerStatus, ctx context.Context) error + UnregisterWorker(id string, ctx context.Context) error +} diff --git a/services/worker-registry/ports/model.go b/services/worker-registry/ports/model.go new file mode 100644 index 0000000..9e9eeda --- /dev/null +++ b/services/worker-registry/ports/model.go @@ -0,0 +1,23 @@ +package ports + +type Worker struct { + Id string `json:"id"` + Status WorkerStatus `json:"status"` + Location Location `json:"location"` +} + +type Location struct { + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` +} + +type WorkerPatch struct { + Status WorkerStatus `json:"status"` +} + +type WorkerStatus string + +const ( + REQUIRES_WORK WorkerStatus = "REQUIRES_WORK" + EXHAUSTED WorkerStatus = "EXHAUSTED" +) diff --git a/services/worker-registry/ports/repo.go b/services/worker-registry/ports/repo.go new file mode 100644 index 0000000..6486895 --- /dev/null +++ b/services/worker-registry/ports/repo.go @@ -0,0 +1,13 @@ +package ports + +import ( + "context" +) + +type Repo interface { + Store(worker Worker, ctx context.Context) + FindById(id string, ctx context.Context) (Worker, error) + GetAll() []Worker + GetFiltered(filter func(Worker) bool) []Worker + Delete(id string) +}