commit 7186ef0c499f26be64974614cf03a7c98681ce13 Author: seonNoh Date: Wed Sep 23 23:35:10 2026 +0900 feat: add legacy-api service 실험·앱 계층 설계서 3.2의 legacy-api(VM 계층). /healthz(프로세스 생존), /v1/price/{item}(db-primary app.price 조회, DB 불가 시 503), /metrics(Prometheus). Go 1.27.1, pgx v5.11.0, client_golang v1.24.1. 이미지는 Cloud Build로 Artifact Registry에 올린다(MIG 인스턴스가 VM SA로 pull). diff --git a/README.md b/README.md new file mode 100644 index 0000000..04526f7 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# selfheal-lab-apps + +Application sources for the self-healing verification lab (VM + container mixed environment). +Infrastructure: `seon-labs/selfheal-lab-terraform`. Runtime configuration (compose, manifests): +`selfheal-lab/selfheal-lab-gitops`. This repository is public and contains no secrets. + +| Directory | Runs on | Image | +|---|---|---| +| `legacy-api/` | GCP MIG instances (docker compose) | Artifact Registry `asia-northeast1-docker.pkg.dev/cms-d-414903/selfheal/legacy-api:` | +| `demo-app/` | AWS Kubernetes (Argo CD) | ECR `selfheal/demo-app` (added in WP-14) | + +Build (Cloud Build, from the repository root): + +``` +gcloud builds submit legacy-api --project cms-d-414903 --tag asia-northeast1-docker.pkg.dev/cms-d-414903/selfheal/legacy-api:$(git rev-parse --short HEAD) +``` diff --git a/legacy-api/Dockerfile b/legacy-api/Dockerfile new file mode 100644 index 0000000..5919d3a --- /dev/null +++ b/legacy-api/Dockerfile @@ -0,0 +1,15 @@ +# legacy-api (VM layer of the self-healing lab). Built with Cloud Build into Artifact Registry. +FROM golang:1.27.1-alpine AS build +WORKDIR /src +ENV CGO_ENABLED=0 GOFLAGS=-mod=mod +COPY go.mod ./ +RUN go mod download +COPY . . +RUN go build -trimpath -ldflags="-s -w" -o /out/legacy-api . + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app +USER 10001 +COPY --from=build /out/legacy-api /usr/local/bin/legacy-api +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/legacy-api"] diff --git a/legacy-api/go.mod b/legacy-api/go.mod new file mode 100644 index 0000000..542a2d4 --- /dev/null +++ b/legacy-api/go.mod @@ -0,0 +1,24 @@ +module github.com/selfheal-lab/selfheal-lab-apps/legacy-api + +go 1.27 + +require ( + github.com/jackc/pgx/v5 v5.11.0 + github.com/prometheus/client_golang v1.24.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/legacy-api/go.sum b/legacy-api/go.sum new file mode 100644 index 0000000..d2c1a89 --- /dev/null +++ b/legacy-api/go.sum @@ -0,0 +1,54 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/legacy-api/main.go b/legacy-api/main.go new file mode 100644 index 0000000..6c10b7a --- /dev/null +++ b/legacy-api/main.go @@ -0,0 +1,112 @@ +// legacy-api: the "VM layer" service of the self-healing lab (experiment design 3.2). +// GET /healthz process liveness only (MIG autohealing + ILB health check) +// GET /v1/price/{item} reads app.price on db-primary (503 when the DB is unreachable) +// GET /metrics Prometheus exposition +package main + +import ( + "context" + "database/sql" + "encoding/json" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + reqTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "legacy_http_requests_total", Help: "HTTP requests by route and status", + }, []string{"route", "status"}) + reqDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "legacy_http_request_duration_seconds", Help: "HTTP request latency", + Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5}, + }, []string{"route"}) +) + +type statusWriter struct { + http.ResponseWriter + code int +} + +func (w *statusWriter) WriteHeader(c int) { w.code = c; w.ResponseWriter.WriteHeader(c) } + +func instrument(route string, h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sw := &statusWriter{ResponseWriter: w, code: 200} + start := time.Now() + h(sw, r) + reqDur.WithLabelValues(route).Observe(time.Since(start).Seconds()) + reqTotal.WithLabelValues(route, strconv.Itoa(sw.code)).Inc() + } +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func main() { + prometheus.MustRegister(reqTotal, reqDur) + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + dsn := os.Getenv("DB_DSN") + var db *sql.DB + if dsn != "" { + var err error + if db, err = sql.Open("pgx", dsn); err != nil { + log.Printf("db open: %v", err) + db = nil + } else { + db.SetMaxOpenConns(8) + db.SetConnMaxLifetime(5 * time.Minute) + } + } else { + log.Printf("DB_DSN not set: /v1/price will answer 503") + } + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", instrument("healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok\n")) + })) + mux.HandleFunc("/v1/price/", instrument("price", func(w http.ResponseWriter, r *http.Request) { + item := strings.TrimPrefix(r.URL.Path, "/v1/price/") + if item == "" || strings.Contains(item, "/") { + writeJSON(w, 404, map[string]string{"error": "item required"}) + return + } + if db == nil { + writeJSON(w, 503, map[string]string{"error": "db unavailable"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + var price float64 + var updated time.Time + err := db.QueryRowContext(ctx, "SELECT price, updated_at FROM app.price WHERE item = $1", item).Scan(&price, &updated) + switch { + case err == sql.ErrNoRows: + writeJSON(w, 404, map[string]string{"error": "unknown item", "item": item}) + case err != nil: + log.Printf("price query: %v", err) + writeJSON(w, 503, map[string]string{"error": "db unavailable"}) + default: + writeJSON(w, 200, map[string]any{"item": item, "price": price, "updated_at": updated.UTC().Format(time.RFC3339)}) + } + })) + mux.Handle("/metrics", promhttp.Handler()) + + srv := &http.Server{Addr: ":" + port, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + log.Printf("legacy-api listening on :%s", port) + log.Fatal(srv.ListenAndServe()) +}