diff --git a/.github/workflows/flaky-service.yaml b/.github/workflows/flaky-service.yaml new file mode 100644 index 0000000..ee46406 --- /dev/null +++ b/.github/workflows/flaky-service.yaml @@ -0,0 +1,56 @@ +name: flaky-service + +on: + push: + branches: [ main ] + paths: + - .github/workflows/flaky-service.yaml + - flaky-service/** + pull_request: + branches: [ main ] + paths: + - .github/workflows/flaky-service.yaml + - flaky-service/** + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: europe-north1-docker.pkg.dev/nais-io/nais/images + +jobs: + build_push_sign: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: nais/platform-build-push-sign@main + id: image + with: + context: ./flaky-service + name: flaky-service + dockerfile: ./Dockerfile + google_service_account: gh-examples + push: ${{ github.actor != 'dependabot[bot]' }} + workload_identity_provider: ${{ secrets.NAIS_IO_WORKLOAD_IDENTITY_PROVIDER }} + outputs: + version: ${{ steps.image.outputs.version }} + rollout: + permissions: + id-token: write + name: Deploy to NAIS + needs: ["build_push_sign"] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - uses: nais/deploy/actions/deploy@v2 + env: + CLUSTER: dev-gcp + PRINT_PAYLOAD: "true" + RESOURCE: "./flaky-service/.nais/unleash.yaml,./flaky-service/.nais/app.yaml" + VAR: image=europe-north1-docker.pkg.dev/nais-io/nais/images/flaky-service:${{ needs.build_push_sign.outputs.version }},namespace=nais \ No newline at end of file diff --git a/flaky-service/.nais/app.yaml b/flaky-service/.nais/app.yaml new file mode 100644 index 0000000..4ba22f0 --- /dev/null +++ b/flaky-service/.nais/app.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: nais.io/v1alpha1 +kind: Application +metadata: + name: flaky-service + namespace: {{ namespace }} + labels: + team: {{ namespace }} +spec: + image: {{ image }} + port: 8080 + prometheus: + enabled: false + envFrom: + - secret: flaky-service-unleash-api-token + accessPolicy: + outbound: + external: + - host: nais-demo-unleash-api.nav.cloud.nais.io + replicas: + max: 1 + min: 1 \ No newline at end of file diff --git a/flaky-service/.nais/unleash.yaml b/flaky-service/.nais/unleash.yaml new file mode 100644 index 0000000..608934c --- /dev/null +++ b/flaky-service/.nais/unleash.yaml @@ -0,0 +1,14 @@ +apiVersion: unleash.nais.io/v1 +kind: ApiToken +metadata: + name: flaky-service + namespace: {{ namespace }} + labels: + team: {{ namespace }} +spec: + unleashInstance: + apiVersion: unleash.nais.io/v1 + kind: RemoteUnleash + name: nais-demo + secretName: flaky-service-unleash-api-token + environment: development diff --git a/flaky-service/Dockerfile b/flaky-service/Dockerfile new file mode 100644 index 0000000..dce8ee9 --- /dev/null +++ b/flaky-service/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.22-alpine as builder +RUN apk add --no-cache git make curl build-base +ENV GOOS=linux +WORKDIR /src +COPY . /src/ +RUN go mod download +RUN go build -o bin/flaky-service *.go + +FROM alpine:3.18 +RUN apk add --no-cache ca-certificates tzdata +RUN export PATH=$PATH:/app +WORKDIR /app +COPY --from=builder /src/bin/flaky-service /app/flaky-service +CMD ["/app/flaky-service"] \ No newline at end of file diff --git a/flaky-service/Makefile b/flaky-service/Makefile new file mode 100644 index 0000000..12c0708 --- /dev/null +++ b/flaky-service/Makefile @@ -0,0 +1,2 @@ +flaky-service: + go build -o bin/flaky-service *.go \ No newline at end of file diff --git a/flaky-service/README.md b/flaky-service/README.md new file mode 100644 index 0000000..5d42679 --- /dev/null +++ b/flaky-service/README.md @@ -0,0 +1,22 @@ +# Flaky Service + +The Flaky Service is a simple service that aims to simulate flaky behavior in a service oriented architecture. + +## Architecture + +The service is a simple HTTP server that listens on port `8080` by default written in Go. It has a single endpoint that returns a `200 OK` response for good requests and a `500 Server Error` response for errors. + +The flakiness is simulated by randomly returning a `500 Server Error` response for a percentage of requests. The flakiness limit can be adjusted using the Unleash feature toggle mentioned below. + +## Endpoint + +The service has only one endpoint: + +### `/` + +- Returns a `200 OK {"message": "hello, world"}` response for good requests. +- Returns a `500 Server Error {"error": "server error"}` response for errors. + +## Adjusting Flakiness + +The flakiness of the service can be adjusted using the Unleash feature toggle. The service checks the `flaky-service.flakiness-limit` feature toggle to determine the flakiness limit. If the feature toggle is not present, the service defaults to a flakiness limit of `50%`. diff --git a/flaky-service/config.go b/flaky-service/config.go new file mode 100644 index 0000000..701a70d --- /dev/null +++ b/flaky-service/config.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/Unleash/unleash-client-go/v3" + "github.com/Unleash/unleash-client-go/v3/api" +) + +const ( + AppName = "flaky-service" + unleashDefaultProject = "default" + unleashDefaultEnv = "development" + unleashDefaultType = "client" + + toggleFlakinessLevelName = "flaky-service.flakiness-level" + toggleFlakinessLevelDefault = 50 +) + +type Config struct { + Server *ServerConfig + Unleash *UnleashConfig +} + +type ServerConfig struct { + Port string `env:"PORT, default=8080"` + Host string `env:"HOST, default=0.0.0.0"` +} + +type UnleashConfig struct { + ClientType string `env:"UNLEASH_SERVER_API_TYPE, default=client"` + Projects string `env:"UNLEASH_SERVER_API_PROJECTS, default=default"` + Env string `env:"UNLEASH_SERVER_API_ENV, default=development"` + Url string `env:"UNLEASH_SERVER_API_URL"` + Token string `env:"UNLEASH_SERVER_API_TOKEN"` +} + +func (c *Config) UnleashInit() { + projects := strings.Split(c.Unleash.Projects, ",") + project := unleashDefaultProject + if len(projects) > 0 { + project = projects[0] + } + + unleash.Initialize( + // unleash.WithListener(&unleash.DebugListener{}), + unleash.WithAppName(AppName), + unleash.WithEnvironment(c.Unleash.Env), + unleash.WithUrl(fmt.Sprintf("%s/api", c.Unleash.Url)), + unleash.WithProjectName(project), + unleash.WithCustomHeaders(http.Header{"Authorization": {fmt.Sprintf("Bearer %s", c.Unleash.Token)}}), + ) +} + +func (c *Config) FlakinessLevel() int { + variant := unleash.GetVariant(toggleFlakinessLevelName, unleash.WithVariantFallback(api.GetDefaultVariant())) + + if variant == nil { + slog.Info("Failed to get variant", "featureToggleName", toggleFlakinessLevelName) + return toggleFlakinessLevelDefault + } + + if variant.Payload.Type != "number" { + slog.Info("Invalid variant payload type", "featureToggleName", toggleFlakinessLevelName, "type", variant.Payload.Type, "value", variant.Payload.Value) + return toggleFlakinessLevelDefault + } + + value, err := strconv.Atoi(variant.Payload.Value) + if err != nil { + slog.Info("Failed to parse variant value", "featureToggleName", toggleFlakinessLevelName, "type", variant.Payload.Type, "value", variant.Payload.Value, "error", err) + return toggleFlakinessLevelDefault + } + return value +} diff --git a/flaky-service/go.mod b/flaky-service/go.mod new file mode 100644 index 0000000..00ef44f --- /dev/null +++ b/flaky-service/go.mod @@ -0,0 +1,18 @@ +module github.com/nais/examples/flaky-service + +go 1.22.3 + +require github.com/joho/godotenv v1.5.1 + +require ( + github.com/Masterminds/semver/v3 v3.1.1 // indirect + github.com/Unleash/unleash-client-go v0.0.0-20190923201156-aae25c357956 + github.com/Unleash/unleash-client-go/v3 v3.9.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sethvargo/go-envconfig v1.0.3 // indirect + github.com/stretchr/objx v0.1.1 // indirect + github.com/stretchr/testify v1.2.2 // indirect + github.com/twmb/murmur3 v1.1.5 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 +) diff --git a/flaky-service/go.sum b/flaky-service/go.sum new file mode 100644 index 0000000..2549bdd --- /dev/null +++ b/flaky-service/go.sum @@ -0,0 +1,24 @@ +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Unleash/unleash-client-go v0.0.0-20190923201156-aae25c357956 h1:KW9P0PdJZyaM/A92zCNzwmLtlu2ZgtTlFRAXmQXwgXQ= +github.com/Unleash/unleash-client-go v0.0.0-20190923201156-aae25c357956/go.mod h1:89lPmFeGRU6Xv5kYZnN1ByXulB4vHW+UepUYSm8s8ws= +github.com/Unleash/unleash-client-go/v3 v3.9.2 h1:/Jl61G/kOx+1+MqPuMnC/JvJxdsf52ZDdJvCmXoA2ck= +github.com/Unleash/unleash-client-go/v3 v3.9.2/go.mod h1:jAf7F2WWpfJbfn1n8bZ74p7hkAhijrqH4TpWoT7kWLc= +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +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/sethvargo/go-envconfig v1.0.3 h1:ZDxFGT1M7RPX0wgDOCdZMidrEB+NrayYr6fL0/+pk4I= +github.com/sethvargo/go-envconfig v1.0.3/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/twmb/murmur3 v1.1.5 h1:i9OLS9fkuLzBXjt6dptlAEyk58fJsSTXbRg3SgVyqgk= +github.com/twmb/murmur3 v1.1.5/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +gopkg.in/h2non/gock.v1 v1.0.10/go.mod h1:KHI4Z1sxDW6P4N3DfTWSEza07YpkQP7KJBfglRMEjKY= diff --git a/flaky-service/main.go b/flaky-service/main.go new file mode 100644 index 0000000..16891d0 --- /dev/null +++ b/flaky-service/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "fmt" + "log" + "log/slog" + "net/http" + + "github.com/joho/godotenv" + "github.com/sethvargo/go-envconfig" + "golang.org/x/exp/rand" +) + +var c Config + +func init() { + ctx := context.Background() + + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file", err) + } + + if err := envconfig.Process(ctx, &c); err != nil { + log.Fatal("Error processing config:", err) + } + + c.UnleashInit() +} + +func main() { + slog.Info("Starting server", "flakinessLevel", c.Server.Host, "port", c.Server.Port) + + // Start a simple HTTP server + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + level := c.FlakinessLevel() + slog.Info("Flakiness level", "level", level) + + w.Header().Set("X-Flakiness-Level", fmt.Sprintf("%d", level)) + + if rand.Intn(100) < level { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "Internal Server Error"}`)) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message": "Hello, World!"}`)) + }) + + log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port), nil)) +}