diff --git a/Makefile b/Makefile index abcede9..ae82c80 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,6 @@ GOENV=GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GO111MODULE=on db/db: db/*.go env $(GOENV) go build -o $@ ./db -app/app: app/*.go - env $(GOENV) go build -o $@ ./app - loadgen/loadgen: loadgen/*.go env $(GOENV) go build -o $@ ./loadgen @@ -26,7 +23,7 @@ db/.uptodate: db/db db/Dockerfile docker tag $(DOCKER_IMAGE_BASE)/tns-db $(DOCKER_IMAGE_BASE)/tns-db:$(IMAGE_TAG) touch $@ -app/.uptodate: app/app app/Dockerfile app/index.html.tmpl +app/.uptodate: app/Dockerfile app/pom.xml $(shell find app/src) docker build -t $(DOCKER_IMAGE_BASE)/tns-app app/ docker tag $(DOCKER_IMAGE_BASE)/tns-app $(DOCKER_IMAGE_BASE)/tns-app:$(IMAGE_TAG) touch $@ diff --git a/app/Dockerfile b/app/Dockerfile index 4a43f5f..017323a 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,6 +1,16 @@ -FROM alpine:3.9 -ADD app / -ADD index.html.tmpl / -EXPOSE 80 -ENTRYPOINT [ "/app" ] -CMD [ "http://db" ] +FROM maven:3.6.3-openjdk-11 as build +WORKDIR /usr/src/app + +# We copy the pom and install the dependencies as a sepearate step so docker will cache them. +COPY pom.xml /usr/src/app +RUN mvn dependency:resolve + +# Now actually build the thing. +COPY src /usr/src/app/src +RUN mvn install -Dmaven.test.skip=true + + +FROM tomcat:9-jre11 +RUN curl -L -o /usr/local/tomcat/opentelemetry-javaagent-all.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v0.15.1/opentelemetry-javaagent-all.jar +COPY --from=build /usr/src/app/target/News.war /usr/local/tomcat/webapps/News.war +ENV JAVA_OPTS="-javaagent:/usr/local/tomcat/opentelemetry-javaagent-all.jar -Dlogging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG '-Dlogging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %logger{36} - %msg traceID=%X{traceId} %n'" diff --git a/app/index.html.tmpl b/app/index.html.tmpl deleted file mode 100644 index 68eb275..0000000 --- a/app/index.html.tmpl +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - Grafana News - - -
- - - - - - - - - - - -
- - - - - - -
- Grafana News - -
-
- - {{ range .Links }} - - - - - - - - - - - {{ end }} -
{{ .Rank }}.{{ .Title }}
- {{ .Points }} points -
-
- - - - -
-
-
-
- Title:
- URL:
- -
-
-
-
- - diff --git a/app/main.go b/app/main.go deleted file mode 100644 index cbdfc01..0000000 --- a/app/main.go +++ /dev/null @@ -1,318 +0,0 @@ -package main - -import ( - "bytes" - "crypto/md5" - "crypto/sha1" - "encoding/binary" - "encoding/json" - "flag" - "fmt" - "io" - "io/ioutil" - "math/rand" - "net/http" - "net/url" - "os" - "strconv" - "strings" - "text/template" - "time" - - "github.com/go-kit/kit/log" - "github.com/go-kit/kit/log/level" - "github.com/grafana/tns/client" - "github.com/weaveworks/common/logging" - "github.com/weaveworks/common/server" - "github.com/weaveworks/common/tracing" -) - -func main() { - serverConfig := server.Config{ - MetricsNamespace: "tns", - } - serverConfig.RegisterFlags(flag.CommandLine) - flag.Parse() - - // Use a gokit logger, and tell the server to use it. - logger := level.NewFilter(log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout)), serverConfig.LogLevel.Gokit) - serverConfig.Log = logging.GoKit(logger) - - // Setting the environment variable JAEGER_AGENT_HOST enables tracing - trace, err := tracing.NewFromEnv("app") - if err != nil { - level.Error(logger).Log("msg", "error initializing tracing", "err", err) - os.Exit(1) - } - defer trace.Close() - - s, err := server.New(serverConfig) - if err != nil { - level.Error(logger).Log("msg", "error starting server", "err", err) - os.Exit(1) - } - defer s.Shutdown() - - databases, err := getDatabases(flag.Args()) - if err != nil { - level.Error(logger).Log("msg", "error parsing databases", "err", err) - os.Exit(1) - } - level.Info(logger).Log("database(s)", len(databases)) - - app, err := new(logger, databases) - if err != nil { - level.Error(logger).Log("msg", "error initialising app", "err", err) - os.Exit(1) - } - - s.HTTP.HandleFunc("/", app.Index) - s.HTTP.HandleFunc("/post", app.Post) - s.HTTP.HandleFunc("/vote", app.Vote) - - s.Run() -} - -func getDatabases(args []string) ([]*url.URL, error) { - databases := []*url.URL{} - for _, host := range args { - u, err := url.Parse(host) - if err != nil { - return nil, err - } - databases = append(databases, u) - } - - return databases, nil -} - -type app struct { - logger log.Logger - databases []*url.URL - - id string - client *client.Client - tmpl *template.Template -} - -type Link struct { - ID int - Rank int - Points int - URL string - Title string -} - -func new(logger log.Logger, databases []*url.URL) (*app, error) { - c := client.New(logger) - - tmpl, err := template.ParseFiles("/index.html.tmpl") - if err != nil { - return nil, err - } - - rand.Seed(time.Now().UnixNano()) - h := md5.New() - fmt.Fprintf(h, "%d", rand.Int63()) - id := fmt.Sprintf("app-%x", h.Sum(nil)) - - return &app{ - logger: logger, - databases: databases, - - tmpl: tmpl, - id: id, - client: c, - }, nil -} - -func (a *app) Index(w http.ResponseWriter, r *http.Request) { - db := a.databases[rand.Intn(len(a.databases))].String() - req, err := http.NewRequest("GET", db, nil) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "%v\n", err) - return - } - req = req.WithContext(r.Context()) - - resp, err := a.client.Do(req) - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, "%v\n", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1024)) - level.Error(a.logger).Log("msg", "HTTP request faild", "status", resp.StatusCode, "body", body) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "%s\n", body) - return - } - - var response struct { - Links []Link - } - - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - level.Error(a.logger).Log("msg", "failed to parse db response", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - for i := range response.Links { - response.Links[i].Rank = i + 1 - } - - w.WriteHeader(http.StatusOK) - if err := a.tmpl.Execute(w, struct { - Now time.Time - ID string - Links []Link - }{ - Now: time.Now(), - ID: a.id, - Links: response.Links, - }); err != nil { - level.Error(a.logger).Log("msg", "failed to execute template", "err", err) - } -} - -func (a *app) Post(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - level.Error(a.logger).Log("msg", "error parsing form", "err", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - u := strings.TrimSpace(r.PostForm.Get("url")) - if u == "" { - level.Error(a.logger).Log("msg", "empty url") - http.Error(w, "empty url", http.StatusBadRequest) - return - } - - parsed, err := url.Parse(u) - if err != nil { - level.Error(a.logger).Log("msg", "invalid url", "url", u, "err", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if parsed.Scheme != "http" && parsed.Scheme != "https" { - parsed.Scheme = "http" - } - - title := strings.TrimSpace(r.PostForm.Get("title")) - if title == "" { - level.Error(a.logger).Log("msg", "empty url") - http.Error(w, "empty title", http.StatusBadRequest) - return - } - - hash := sha1.Sum([]byte(parsed.String())) - id := binary.BigEndian.Uint16(hash[:]) - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(struct { - ID int - URL string - Title string - }{ - ID: int(id), - URL: parsed.String(), - Title: title, - }); err != nil { - level.Error(a.logger).Log("msg", "error encoding post", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - db := a.databases[rand.Intn(len(a.databases))].String() - req, err := http.NewRequest("POST", db+"/post", &buf) - if err != nil { - level.Error(a.logger).Log("msg", "error building request", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - req = req.WithContext(r.Context()) - resp, err := a.client.Do(req) - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, "%v\n", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1024)) - level.Error(a.logger).Log("msg", "HTTP request faild", "status", resp.StatusCode, "body", body) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "%s\n", body) - return - } - - // Implement PRG pattern to prevent double-POST. - newURL := strings.TrimSuffix(req.RequestURI, "/post") - http.Redirect(w, req, newURL, http.StatusFound) - return -} - -func (a *app) Vote(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - level.Error(a.logger).Log("msg", "error parsing form", "err", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - id, err := strconv.Atoi(r.Form.Get("id")) - if err != nil { - level.Error(a.logger).Log("msg", "invalid id", "err", err) - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(struct { - ID int - }{ - ID: id, - }); err != nil { - level.Error(a.logger).Log("msg", "error encoding post", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - db := a.databases[rand.Intn(len(a.databases))].String() - req, err := http.NewRequest("POST", db+"/vote", &buf) - if err != nil { - level.Error(a.logger).Log("msg", "error building request", "err", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - req = req.WithContext(r.Context()) - resp, err := a.client.Do(req) - if err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, "%v\n", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1024)) - level.Error(a.logger).Log("msg", "HTTP request faild", "status", resp.StatusCode, "body", body) - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "%s\n", body) - return - } - - // Implement PRG pattern to prevent double-POST. - newURL := strings.TrimSuffix(req.RequestURI, "/vote") - http.Redirect(w, req, newURL, http.StatusFound) - return -} diff --git a/app/pom.xml b/app/pom.xml new file mode 100644 index 0000000..c2e8e80 --- /dev/null +++ b/app/pom.xml @@ -0,0 +1,96 @@ + + + 4.0.0 + + com.grafana + News + 0.0.1-SNAPSHOT + war + + News + Demo application for Grafana news + + + org.springframework.boot + spring-boot-starter-parent + 2.0.4.RELEASE + + + + + UTF-8 + UTF-8 + 10 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-tomcat + + + + + org.springframework.boot + spring-boot-starter-tomcat + provided + + + org.springframework.boot + spring-boot-devtools + runtime + + + mysql + mysql-connector-java + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + javax.xml.bind + jaxb-api + + + javax.servlet + javax.servlet-api + + + org.javassist + javassist + 3.25.0-GA + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + + + ${artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + diff --git a/app/src/main/java/com/grafana/news/NewsApplication.java b/app/src/main/java/com/grafana/news/NewsApplication.java new file mode 100644 index 0000000..3e7ee87 --- /dev/null +++ b/app/src/main/java/com/grafana/news/NewsApplication.java @@ -0,0 +1,21 @@ +package com.grafana.news; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class NewsApplication extends SpringBootServletInitializer { + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + return application.sources(NewsApplication.class); + } + + public static void main(String[] args) { + SpringApplication.run(NewsApplication.class, args); + } +} diff --git a/app/src/main/java/com/grafana/news/RequestLoggingFilterConfig.java b/app/src/main/java/com/grafana/news/RequestLoggingFilterConfig.java new file mode 100644 index 0000000..77c264e --- /dev/null +++ b/app/src/main/java/com/grafana/news/RequestLoggingFilterConfig.java @@ -0,0 +1,19 @@ +package com.grafana.news; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.CommonsRequestLoggingFilter; + +@Configuration +public class RequestLoggingFilterConfig { + + @Bean + public CommonsRequestLoggingFilter logFilter() { + CommonsRequestLoggingFilter filter + = new CommonsRequestLoggingFilter(); + filter.setIncludeQueryString(true); + filter.setIncludeHeaders(true); + filter.setIncludeClientInfo(true); + return filter; + } +} diff --git a/app/src/main/java/com/grafana/news/controller/PostController.java b/app/src/main/java/com/grafana/news/controller/PostController.java new file mode 100644 index 0000000..4d40674 --- /dev/null +++ b/app/src/main/java/com/grafana/news/controller/PostController.java @@ -0,0 +1,47 @@ +package com.grafana.news.controller; + +import java.util.List; +import java.util.Map; + +import javax.validation.Valid; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.grafana.news.exception.ResourceNotFoundException; +import com.grafana.news.model.Post; +import com.grafana.news.repository.PostRepository; + +@RestController +@RequestMapping("/") +public class PostController { + @Autowired + PostRepository postRepository; + + @GetMapping("/") + public List getPosts() { + return postRepository.findAll(); + } + + @PutMapping("/post") + public Post createPost(@Valid @RequestBody Post post) { + return postRepository.save(post); + } + + @PostMapping("/vote") + public ResponseEntity upvote(@PathVariable(value = "id") Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResourceNotFoundException("Post", "id", postId)); + post.upvote(); + postRepository.save(post); + return ResponseEntity.ok().build(); + } +} diff --git a/app/src/main/java/com/grafana/news/exception/ResourceNotFoundException.java b/app/src/main/java/com/grafana/news/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..7bdf943 --- /dev/null +++ b/app/src/main/java/com/grafana/news/exception/ResourceNotFoundException.java @@ -0,0 +1,30 @@ +package com.grafana.news.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + private String resourceName; + private String fieldName; + private Object fieldValue; + + public ResourceNotFoundException( String resourceName, String fieldName, Object fieldValue) { + super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue)); + this.resourceName = resourceName; + this.fieldName = fieldName; + this.fieldValue = fieldValue; + } + + public String getResourceName() { + return resourceName; + } + + public String getFieldName() { + return fieldName; + } + + public Object getFieldValue() { + return fieldValue; + } +} diff --git a/app/src/main/java/com/grafana/news/model/Post.java b/app/src/main/java/com/grafana/news/model/Post.java new file mode 100644 index 0000000..65d65d9 --- /dev/null +++ b/app/src/main/java/com/grafana/news/model/Post.java @@ -0,0 +1,85 @@ +package com.grafana.news.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import javax.persistence.*; +import javax.validation.constraints.NotBlank; + +import java.io.Serializable; +import java.util.Date; + +@Entity +@Table(name = "post") +@EntityListeners(AuditingEntityListener.class) +@JsonIgnoreProperties(value = {"createdAt"}, allowGetters = true) +public class Post implements Serializable { + /** + * + */ + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + private String title; + + @Column(nullable = false, updatable = false) + @Temporal(TemporalType.TIMESTAMP) + @CreatedDate + private Date createdAt; + + @NotBlank + private String url; + + @NotBlank + private Long points; + + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public Long getPoints() { + return points; + } + + public void setPoints(Long points) { + this.points = points; + } + + public void upvote() { + this.points++; + } +} diff --git a/app/src/main/java/com/grafana/news/repository/PostRepository.java b/app/src/main/java/com/grafana/news/repository/PostRepository.java new file mode 100644 index 0000000..bf968c2 --- /dev/null +++ b/app/src/main/java/com/grafana/news/repository/PostRepository.java @@ -0,0 +1,11 @@ +package com.grafana.news.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.grafana.news.model.Post; + +@Repository +public interface PostRepository extends JpaRepository { + +} diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties new file mode 100644 index 0000000..d855298 --- /dev/null +++ b/app/src/main/resources/application.properties @@ -0,0 +1,15 @@ +## Spring DATASOURCE (DataSourceAutoConfiguration & DataSourceProperties) +spring.datasource.url = jdbc:mysql://mysql/news?allowPublicKeyRetrieval=true&useSSL=false +spring.datasource.username = root +spring.datasource.password = password + +## Hibernate Properties +# The SQL dialect makes Hibernate generate better SQL for the chosen database +spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect + +# Hibernate ddl auto (create, create-drop, validate, update) +spring.jpa.hibernate.ddl-auto = update + +management.server.port=8080 +management.server.ssl.enabled=false +management.endpoints.web.exposure.include=* diff --git a/app/src/test/java/com/grafana/news/NewsApplicationTests.java b/app/src/test/java/com/grafana/news/NewsApplicationTests.java new file mode 100644 index 0000000..e7c408f --- /dev/null +++ b/app/src/test/java/com/grafana/news/NewsApplicationTests.java @@ -0,0 +1,16 @@ +package com.grafana.news; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class NewsApplicationTests { + + @Test + public void contextLoads() { + } + +} diff --git a/production/docker-compose.yml b/production/docker-compose.yml index e5f3904..2ca581d 100644 --- a/production/docker-compose.yml +++ b/production/docker-compose.yml @@ -1,6 +1,14 @@ version: "2" services: + mysql: + image: mysql:8 + container_name: mysql_service + restart: always + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: news + db: image: grafana/tns-db:9c1ab38 command: @@ -14,19 +22,17 @@ services: JAEGER_SAMPLER_PARAM: 1 app: - image: grafana/tns-app:9c1ab38 - command: - - '-log.level=debug' - - 'http://db' - links: - - db + image: grafana/tns-app ports: - - 0.0.0.0:8001:80 + - 8080:8080 + - 8009:8009 + depends_on: + - mysql environment: - JAEGER_ENDPOINT: 'http://tempo:14268/api/traces' - JAEGER_TAGS: cluster=tns,namespace=tns - JAEGER_SAMPLER_TYPE: const - JAEGER_SAMPLER_PARAM: 1 + - OTEL_EXPORTER=otlp_span,prometheus + - OTEL_EXPORTER_OTLP_ENDPOINT=tempo:55680 + - OTEL_EXPORTER_OTLP_INSECURE=true + - OTEL_RESOURCE_ATTRIBUTES=service.name=demo loadgen: image: grafana/tns-loadgen:9c1ab38