├── .dockerignore ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── Makefile ├── README.md ├── access_counter.go ├── access_counter_test.go ├── auth.go ├── auth_test.go ├── cmd └── mirage-ecs │ └── main.go ├── config.go ├── config_auth_test.go ├── config_sample.yml ├── config_test.go ├── data └── .gitkeep ├── docker ├── Dockerfile └── example-config.yml ├── docs ├── mirage-ecs-launcher.png └── mirage-ecs-list.png ├── e2e_test.go ├── ecs-task-def.json ├── ecs.go ├── ecs_test.go ├── export_test.go ├── go.mod ├── go.sum ├── html ├── launcher.html ├── layout.html └── list.html ├── local.go ├── log.go ├── mirage.go ├── purge.go ├── purge_test.go ├── reverseproxy.go ├── reverseproxy_test.go ├── route53.go ├── terraform ├── .gitignore ├── .terraform-version ├── .terraform.lock.hcl ├── README.md ├── acm.tf ├── alb.tf ├── config.tf ├── config.yaml ├── ecs-service-def.jsonnet ├── ecs-task-def.jsonnet ├── ecs.tf ├── ecspresso.jsonnet ├── iam.tf ├── logs.tf ├── route53.tf ├── s3.tf ├── sg.tf └── vpc.tf ├── transport_test.go ├── types.go ├── webapi.go └── webapi_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | pkg 2 | terraform/ 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "!**/*" 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: "1.24" 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v5 22 | with: 23 | version: latest 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | - name: Release Image 28 | run: | 29 | echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin 30 | make push-image 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go: 8 | - "1.22" 9 | - "1.23" 10 | - "1.24" 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go }} 18 | id: go 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v4 22 | 23 | - name: Build & Test 24 | run: | 25 | make test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | mirage 3 | ./mirage-ecs 4 | *~ 5 | .envrc 6 | pkg 7 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | - go mod download 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | main: cmd/mirage-ecs/main.go 10 | goos: 11 | - windows 12 | - darwin 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | ldflags: -s -w -X main.Version={{.Version}} -X main.buildDate={{.Date}} 18 | archives: 19 | - files: 20 | - config_sample.yml 21 | - html/* 22 | release: 23 | prerelease: auto 24 | checksum: 25 | name_template: "checksums.txt" 26 | snapshot: 27 | name_template: "{{ .Tag }}-next" 28 | changelog: 29 | sort: asc 30 | filters: 31 | exclude: 32 | - "^docs:" 33 | - "^test:" 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT_VER := $(shell git describe --tags) 2 | DATE := $(shell date +%Y-%m-%dT%H:%M:%S%z) 3 | export GO111MODULE := on 4 | 5 | mirage-ecs: *.go cmd/mirage-ecs/*.go go.mod go.sum 6 | CGO_ENABLED=0 go build -ldflags "-X main.Version=$(GIT_VER) -X main.buildDate=$(DATE)" -o mirage-ecs ./cmd/mirage-ecs/main.go 7 | 8 | clean: 9 | rm -rf dist/* mirage-ecs 10 | 11 | run: mirage-ecs 12 | ./mirage-ecs 13 | 14 | packages: 15 | goreleaser release --rm-dist --snapshot --skip-publish 16 | 17 | docker-image: 18 | docker build -t ghcr.io/acidlemon/mirage-ecs:$(GIT_VER) -f docker/Dockerfile . 19 | 20 | push-image: docker-image 21 | docker push ghcr.io/acidlemon/mirage-ecs:$(GIT_VER) 22 | 23 | test: 24 | go test -v ./... 25 | 26 | install: 27 | go install github.com/acidlemon/mirage-ecs/v2/cmd/mirage-ecs 28 | -------------------------------------------------------------------------------- /access_counter.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // accessCount is a map for access count 9 | // key is a time truncated by accessCounter.unit 10 | type accessCount map[time.Time]int64 11 | 12 | // accessCounter is a thread-safe counter for access 13 | type AccessCounter struct { 14 | mu *sync.Mutex 15 | unit time.Duration 16 | count accessCount 17 | } 18 | 19 | // NewAccessCounter returns a new access counter 20 | // unit is the time unit for the counter (default: time.Minute) 21 | func NewAccessCounter(unit time.Duration) *AccessCounter { 22 | if unit == 0 { 23 | unit = time.Minute 24 | } 25 | c := &AccessCounter{ 26 | mu: new(sync.Mutex), 27 | count: make(accessCount, 2), // 2 is enough for most cases 28 | unit: unit, 29 | } 30 | c.fill() 31 | return c 32 | } 33 | 34 | // Add increments the access counter 35 | func (c *AccessCounter) Add() { 36 | c.mu.Lock() 37 | defer c.mu.Unlock() 38 | now := time.Now().Truncate(c.unit) 39 | c.count[now]++ 40 | } 41 | 42 | // Collect returns the access count and resets the counter 43 | func (c *AccessCounter) Collect() accessCount { 44 | c.mu.Lock() 45 | defer c.mu.Unlock() 46 | r := make(accessCount, len(c.count)) 47 | for k, v := range c.count { 48 | r[k] = v 49 | delete(c.count, k) 50 | } 51 | c.fill() 52 | return r 53 | } 54 | 55 | func (c *AccessCounter) fill() { 56 | c.count[time.Now().Truncate(c.unit)] = 0 57 | } 58 | -------------------------------------------------------------------------------- /access_counter_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 8 | ) 9 | 10 | func TestAccessCounter(t *testing.T) { 11 | c := mirageecs.NewAccessCounter(time.Second) 12 | start := time.Now().Truncate(time.Second) 13 | c.Add() 14 | c.Add() 15 | c.Add() 16 | time.Sleep(time.Second) 17 | c.Add() 18 | c.Add() 19 | c.Add() 20 | c.Add() 21 | c.Add() 22 | r := c.Collect() 23 | if len(r) != 2 { 24 | t.Errorf("could not collect access count %#v", r) 25 | } 26 | if r[start] != 3 { 27 | t.Errorf("could not collect access count %#v", r) 28 | } 29 | if r[start.Add(time.Second)] != 5 { 30 | t.Errorf("could not collect access count %#v", r) 31 | } 32 | r2 := c.Collect() 33 | for _, v := range r2 { 34 | if v != 0 { 35 | t.Errorf("counter should be zero %#v", r2) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/fujiwara/go-amzn-oidc/validator" 13 | "github.com/golang-jwt/jwt/v4" 14 | ) 15 | 16 | type Auth struct { 17 | Basic *AuthMethodBasic `yaml:"basic"` 18 | Token *AuthMethodToken `yaml:"token"` 19 | AmznOIDC *AuthMethodAmznOIDC `yaml:"amzn_oidc"` 20 | CookieSecret string `yaml:"cookie_secret"` 21 | 22 | jwtParser *jwt.Parser 23 | jwtKeyFunc func(*jwt.Token) (interface{}, error) 24 | once sync.Once 25 | } 26 | 27 | type Authorizer func(req *http.Request, res http.ResponseWriter) (bool, error) 28 | 29 | func (a *Auth) ByBasic(req *http.Request, res http.ResponseWriter) (bool, error) { 30 | if a == nil || a.Basic == nil { 31 | return false, nil 32 | } 33 | if ok := a.Basic.Match(req.Header); ok { 34 | slog.Debug("basic auth succeeded") 35 | return ok, nil 36 | } else { 37 | slog.Debug("basic auth failed. set WWW-Authenticate header") 38 | res.Header().Set("WWW-Authenticate", "Basic realm=\"Restricted\"") 39 | } 40 | return false, nil 41 | } 42 | 43 | func (a *Auth) ByToken(req *http.Request, res http.ResponseWriter) (bool, error) { 44 | if a == nil || a.Token == nil { 45 | return false, nil 46 | } 47 | if ok := a.Token.Match(req.Header); ok { 48 | slog.Debug("token auth succeeded") 49 | return ok, nil 50 | } 51 | slog.Debug("token auth failed") 52 | return false, nil 53 | } 54 | 55 | func (a *Auth) ByAmznOIDC(req *http.Request, res http.ResponseWriter) (bool, error) { 56 | if a == nil || a.AmznOIDC == nil { 57 | return false, nil 58 | } 59 | if ok, err := a.AmznOIDC.Match(req.Header); err != nil { 60 | return false, err 61 | } else if ok { 62 | slog.Debug("amzn_oidc auth succeeded") 63 | return true, nil 64 | } 65 | slog.Debug("amzn_oidc auth failed") 66 | return false, nil 67 | } 68 | 69 | func (a *Auth) Do(req *http.Request, res http.ResponseWriter, runs ...Authorizer) (bool, error) { 70 | if a == nil { 71 | // no auth 72 | return true, nil 73 | } 74 | for _, run := range runs { 75 | if ok, err := run(req, res); err != nil { 76 | return false, fmt.Errorf("authorizer %v errored: %w", run, err) 77 | } else if ok { 78 | return true, nil 79 | } 80 | } 81 | return false, nil 82 | } 83 | 84 | func (a *Auth) NewAuthCookie(expire time.Duration, domain string) (*http.Cookie, error) { 85 | expireAt := time.Now().Add(expire) 86 | 87 | if a == nil || a.CookieSecret == "" { 88 | return &http.Cookie{}, nil 89 | } 90 | 91 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 92 | "expire_at": expireAt.Unix(), 93 | }) 94 | tokenStr, err := token.SignedString([]byte(a.CookieSecret)) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to sign cookie: %w", err) 97 | } 98 | return &http.Cookie{ 99 | Name: AuthCookieName, 100 | Value: tokenStr, 101 | Expires: expireAt, 102 | Domain: domain, 103 | HttpOnly: true, 104 | SameSite: http.SameSiteLaxMode, 105 | Secure: true, 106 | }, nil 107 | } 108 | 109 | func (a *Auth) ValidateAuthCookie(c *http.Cookie) error { 110 | if a == nil || a.CookieSecret == "" { 111 | return fmt.Errorf("cookie_secret is not set") 112 | } 113 | a.once.Do(func() { 114 | a.jwtParser = jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) 115 | a.jwtKeyFunc = func(token *jwt.Token) (interface{}, error) { 116 | return []byte(a.CookieSecret), nil 117 | } 118 | }) 119 | token, err := a.jwtParser.Parse(c.Value, a.jwtKeyFunc) 120 | if err != nil { 121 | return fmt.Errorf("failed to parse cookie: %w", err) 122 | } 123 | if !token.Valid { 124 | return fmt.Errorf("invalid cookie: %v", token) 125 | } 126 | claims, ok := token.Claims.(jwt.MapClaims) 127 | if !ok { 128 | return fmt.Errorf("invalid claims: %v", token.Claims) 129 | } 130 | expireAt, ok := claims["expire_at"].(float64) 131 | if !ok { 132 | return fmt.Errorf("invalid expire_at: %v", claims["expire_at"]) 133 | } 134 | if time.Now().Unix() >= int64(expireAt) { 135 | return fmt.Errorf("already expired: %v", expireAt) 136 | } 137 | return nil 138 | } 139 | 140 | type AuthMethodBasic struct { 141 | Username string `yaml:"username"` 142 | Password string `yaml:"password"` 143 | 144 | gen sync.Once 145 | expected string 146 | } 147 | 148 | func (b *AuthMethodBasic) Match(h http.Header) bool { 149 | if b == nil { 150 | return false 151 | } 152 | slog.Debug(f("auth basic %s %s", b.Username, b.Password)) 153 | if b.Username == "" || b.Password == "" || h.Get("Authorization") == "" { 154 | return false 155 | } 156 | b.gen.Do(func() { 157 | b.expected = "Basic " + base64.StdEncoding.EncodeToString([]byte(b.Username+":"+b.Password)) 158 | }) 159 | slog.Debug(f("auth basic comparing %s %s %s", b.Username, b.Password, h.Get("Authorization"))) 160 | if h.Get("Authorization") == b.expected { 161 | slog.Debug(f("auth basic succeeded")) 162 | return true 163 | } 164 | slog.Warn("auth basic failed") 165 | return false 166 | } 167 | 168 | type AuthMethodToken struct { 169 | Token string `yaml:"token"` 170 | Header string `yaml:"header"` 171 | } 172 | 173 | func (b *AuthMethodToken) Match(h http.Header) bool { 174 | if b == nil { 175 | return false 176 | } 177 | sent := h.Get(b.Header) 178 | if b.Token == "" || sent == "" { 179 | return false 180 | } 181 | slog.Debug(f("auth token comparing %s %s %s", b.Header, b.Token, sent)) 182 | if b.Token == sent { 183 | slog.Debug("auth token succeeded") 184 | return true 185 | } 186 | slog.Warn(f("auth token (header=%s) does not match", b.Header)) 187 | return false 188 | } 189 | 190 | type AuthMethodAmznOIDC struct { 191 | Claim string `yaml:"claim"` // e.g. "email" see alsohttps://openid.net/specs/openid-connect-core-1_0.html#StandardClaims 192 | Matchers []*ClaimMatcher `yaml:"matchers"` 193 | } 194 | 195 | func (a *AuthMethodAmznOIDC) Match(h http.Header) (bool, error) { 196 | if a == nil { 197 | return false, nil 198 | } 199 | if a.Claim == "" { 200 | return false, nil 201 | } 202 | slog.Debug(f("auth amzn_oidc comparing %s with %s", a.Claim, h.Get("x-amzn-oidc-data"))) 203 | claims, err := validator.Validate(h.Get("x-amzn-oidc-data")) 204 | if err != nil { 205 | return false, fmt.Errorf("failed to validate x-amzn-oidc-data: %s", err) 206 | } 207 | return a.MatchClaims(claims), nil 208 | } 209 | 210 | func (a *AuthMethodAmznOIDC) MatchClaims(claims map[string]interface{}) bool { 211 | v, ok := claims[a.Claim] 212 | if !ok { 213 | slog.Warn(f("auth amzn_oidc claim[%s] not found in claims", a.Claim)) 214 | return false 215 | } 216 | vs, ok := v.(string) 217 | if !ok { 218 | slog.Warn(f("auth amzn_oidc claim[%s] is not a string: %v", a.Claim, v)) 219 | return false 220 | } 221 | for _, m := range a.Matchers { 222 | if m.Match(vs) { 223 | slog.Debug(f("auth amzn_oidc claim[%s]=%s matches %#v", a.Claim, v, m)) 224 | return true 225 | } 226 | slog.Debug(f("auth amzn_oidc claim[%s]=%s does not match %#v", a.Claim, v, m)) 227 | } 228 | slog.Warn(f("auth amzn_oidc claim[%s]=%s does not match any matchers", a.Claim, vs)) 229 | return false 230 | } 231 | 232 | type ClaimMatcher struct { 233 | Exact string `yaml:"exact"` 234 | Suffix string `yaml:"suffix"` 235 | } 236 | 237 | func (m *ClaimMatcher) Match(s string) bool { 238 | if m.Exact != "" { 239 | return m.Exact == s 240 | } else if m.Suffix != "" { 241 | return strings.HasSuffix(s, m.Suffix) 242 | } else { 243 | return false 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /auth_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "time" 7 | 8 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 9 | ) 10 | 11 | func TestAuthMethodToken_Match(t *testing.T) { 12 | type fields struct { 13 | Token string 14 | Header string 15 | } 16 | type args struct { 17 | h http.Header 18 | } 19 | tests := []struct { 20 | name string 21 | fields fields 22 | args args 23 | want bool 24 | }{ 25 | { 26 | name: "Token matches", 27 | fields: fields{ 28 | Token: "mytoken", 29 | Header: "Authorization", 30 | }, 31 | args: args{ 32 | h: http.Header{ 33 | "Authorization": []string{"mytoken"}, 34 | }, 35 | }, 36 | want: true, 37 | }, 38 | { 39 | name: "Token does not match", 40 | fields: fields{ 41 | Token: "mytoken", 42 | Header: "Authorization", 43 | }, 44 | args: args{ 45 | h: http.Header{ 46 | "Authorization": []string{"othertoken"}, 47 | }, 48 | }, 49 | want: false, 50 | }, 51 | { 52 | name: "Token is empty", 53 | fields: fields{ 54 | Token: "", 55 | Header: "Authorization", 56 | }, 57 | args: args{ 58 | h: http.Header{ 59 | "Authorization": []string{"mytoken"}, 60 | }, 61 | }, 62 | want: false, 63 | }, 64 | { 65 | name: "Header is empty", 66 | fields: fields{ 67 | Token: "mytoken", 68 | Header: "", 69 | }, 70 | args: args{ 71 | h: http.Header{ 72 | "Authorization": []string{"mytoken"}, 73 | }, 74 | }, 75 | want: false, 76 | }, 77 | { 78 | name: "Header does not exist", 79 | fields: fields{ 80 | Token: "mytoken", 81 | Header: "Authorization", 82 | }, 83 | args: args{ 84 | h: http.Header{}, 85 | }, 86 | want: false, 87 | }, 88 | { 89 | name: "Nil AuthMethodToken", 90 | fields: fields{ 91 | Token: "", 92 | Header: "", 93 | }, 94 | args: args{ 95 | h: http.Header{}, 96 | }, 97 | want: false, 98 | }, 99 | } 100 | for _, tt := range tests { 101 | t.Run(tt.name, func(t *testing.T) { 102 | b := &mirageecs.AuthMethodToken{ 103 | Token: tt.fields.Token, 104 | Header: tt.fields.Header, 105 | } 106 | if got := b.Match(tt.args.h); got != tt.want { 107 | t.Errorf("mirageecs.mirageecs.AuthMethodToken.Match() = %v, want %v", got, tt.want) 108 | } 109 | }) 110 | } 111 | } 112 | 113 | func TestAuthMethodAmznOIDC_Match(t *testing.T) { 114 | type fields struct { 115 | Claim string 116 | Matchers []*mirageecs.ClaimMatcher 117 | } 118 | type args struct { 119 | Claims map[string]any 120 | } 121 | tests := []struct { 122 | name string 123 | fields fields 124 | args args 125 | want bool 126 | }{ 127 | { 128 | name: "Claim matches", 129 | fields: fields{ 130 | Claim: "email", 131 | Matchers: []*mirageecs.ClaimMatcher{ 132 | { 133 | Exact: "user@example.com", 134 | }, 135 | }, 136 | }, 137 | args: args{ 138 | Claims: map[string]any{ 139 | "email": "user@example.com", 140 | }, 141 | }, 142 | want: true, 143 | }, 144 | { 145 | name: "Claim does not match", 146 | fields: fields{ 147 | Claim: "email", 148 | Matchers: []*mirageecs.ClaimMatcher{ 149 | { 150 | Exact: "user@example.com", 151 | }, 152 | }, 153 | }, 154 | args: args{ 155 | Claims: map[string]any{ 156 | "email": "xxx@example.com", 157 | }, 158 | }, 159 | want: false, 160 | }, 161 | { 162 | name: "Claim is empty", 163 | fields: fields{ 164 | Claim: "", 165 | Matchers: []*mirageecs.ClaimMatcher{ 166 | { 167 | Exact: "user@example.com", 168 | }, 169 | }, 170 | }, 171 | args: args{ 172 | Claims: map[string]any{ 173 | "email": "user@example.com", 174 | }, 175 | }, 176 | want: false, 177 | }, 178 | { 179 | name: "Claim matches suffix", 180 | fields: fields{ 181 | Claim: "email", 182 | Matchers: []*mirageecs.ClaimMatcher{ 183 | { 184 | Suffix: "@example.com", 185 | }, 186 | }, 187 | }, 188 | args: args{ 189 | Claims: map[string]any{ 190 | "email": "user@example.com", 191 | }, 192 | }, 193 | want: true, 194 | }, 195 | { 196 | name: "Claim does not match suffix", 197 | fields: fields{ 198 | Claim: "email", 199 | Matchers: []*mirageecs.ClaimMatcher{ 200 | { 201 | Suffix: "@example.com", 202 | }, 203 | }, 204 | }, 205 | args: args{ 206 | Claims: map[string]any{ 207 | "email": "user@example.net", 208 | }, 209 | }, 210 | want: false, 211 | }, 212 | { 213 | name: "Claim matches any suffix", 214 | fields: fields{ 215 | Claim: "email", 216 | Matchers: []*mirageecs.ClaimMatcher{ 217 | { 218 | Suffix: "@example.com", 219 | }, 220 | { 221 | Suffix: "@example.net", 222 | }, 223 | }, 224 | }, 225 | args: args{ 226 | Claims: map[string]any{ 227 | "email": "user@example.net", 228 | }, 229 | }, 230 | want: true, 231 | }, 232 | { 233 | name: "Claim matches any exact", 234 | fields: fields{ 235 | Claim: "email", 236 | Matchers: []*mirageecs.ClaimMatcher{ 237 | { 238 | Exact: "foo@example.com", 239 | }, 240 | { 241 | Exact: "bar@example.net", 242 | }, 243 | }, 244 | }, 245 | args: args{ 246 | Claims: map[string]any{ 247 | "email": "bar@example.net", 248 | }, 249 | }, 250 | want: true, 251 | }, 252 | { 253 | name: "Claim match both", 254 | fields: fields{ 255 | Claim: "email", 256 | Matchers: []*mirageecs.ClaimMatcher{ 257 | { 258 | Suffix: "@example.com", 259 | }, 260 | { 261 | Exact: "user@example.com", 262 | }, 263 | }, 264 | }, 265 | args: args{ 266 | Claims: map[string]any{ 267 | "email": "user@example.com", 268 | }, 269 | }, 270 | want: true, 271 | }, 272 | { 273 | name: "Claim does not match both", 274 | fields: fields{ 275 | Claim: "email", 276 | Matchers: []*mirageecs.ClaimMatcher{ 277 | { 278 | Suffix: "@example.net", 279 | }, 280 | { 281 | Exact: "user@example.net", 282 | }, 283 | }, 284 | }, 285 | args: args{ 286 | Claims: map[string]any{ 287 | "email": "user@example.com", 288 | }, 289 | }, 290 | want: false, 291 | }, 292 | } 293 | for _, tt := range tests { 294 | t.Run(tt.name, func(t *testing.T) { 295 | a := &mirageecs.AuthMethodAmznOIDC{ 296 | Claim: tt.fields.Claim, 297 | Matchers: tt.fields.Matchers, 298 | } 299 | got := a.MatchClaims(tt.args.Claims) 300 | if got != tt.want { 301 | t.Errorf("AuthMethodAmznOIDC.MatchClaims() = %v, want %v", got, tt.want) 302 | } 303 | }) 304 | } 305 | } 306 | 307 | func TestAuthMethodBasic_Match(t *testing.T) { 308 | type fields struct { 309 | Username string 310 | Password string 311 | } 312 | type args struct { 313 | h http.Header 314 | } 315 | tests := []struct { 316 | name string 317 | fields fields 318 | args args 319 | want bool 320 | }{ 321 | { 322 | name: "Username and password match", 323 | fields: fields{ 324 | Username: "user", 325 | Password: "pass", 326 | }, 327 | args: args{ 328 | h: http.Header{ 329 | "Authorization": []string{"Basic dXNlcjpwYXNz"}, 330 | }, 331 | }, 332 | want: true, 333 | }, 334 | { 335 | name: "Username and password do not match", 336 | fields: fields{ 337 | Username: "user", 338 | Password: "pass", 339 | }, 340 | args: args{ 341 | h: http.Header{ 342 | "Authorization": []string{"Basic dXNlcnM6cGFzcw=="}, 343 | }, 344 | }, 345 | want: false, 346 | }, 347 | { 348 | name: "Username is empty", 349 | fields: fields{ 350 | Username: "", 351 | Password: "pass", 352 | }, 353 | args: args{ 354 | h: http.Header{ 355 | "Authorization": []string{"Basic OnBhc3M="}, 356 | }, 357 | }, 358 | want: false, 359 | }, 360 | { 361 | name: "Password is empty", 362 | fields: fields{ 363 | Username: "user", 364 | Password: "", 365 | }, 366 | args: args{ 367 | h: http.Header{ 368 | "Authorization": []string{"Basic dXNlcjo="}, 369 | }, 370 | }, 371 | want: false, 372 | }, 373 | { 374 | name: "Authorization header is empty", 375 | fields: fields{ 376 | Username: "user", 377 | Password: "pass", 378 | }, 379 | args: args{ 380 | h: http.Header{}, 381 | }, 382 | want: false, 383 | }, 384 | { 385 | name: "Nil AuthMethodBasic", 386 | fields: fields{ 387 | Username: "", 388 | Password: "", 389 | }, 390 | args: args{ 391 | h: http.Header{}, 392 | }, 393 | want: false, 394 | }, 395 | } 396 | for _, tt := range tests { 397 | t.Run(tt.name, func(t *testing.T) { 398 | b := &mirageecs.AuthMethodBasic{ 399 | Username: tt.fields.Username, 400 | Password: tt.fields.Password, 401 | } 402 | if got := b.Match(tt.args.h); got != tt.want { 403 | t.Errorf("AuthMethodBasic.Match() = %v, want %v", got, tt.want) 404 | } 405 | }) 406 | } 407 | } 408 | 409 | func TestAuthCookie(t *testing.T) { 410 | auth := mirageecs.Auth{ 411 | CookieSecret: "secret", 412 | } 413 | cookie, err := auth.NewAuthCookie(time.Second, ".example.com") 414 | if err != nil { 415 | t.Error(err) 416 | } 417 | if cookie.Name != "mirage-ecs-auth" { 418 | t.Errorf("invalid cookie name: %s", cookie.Name) 419 | } 420 | if cookie.Value == "" { 421 | t.Errorf("invalid cookie value: %s", cookie.Value) 422 | } 423 | if cookie.Domain != ".example.com" { 424 | t.Errorf("invalid cookie domain: %s", cookie.Domain) 425 | } 426 | if cookie.HttpOnly != true { 427 | t.Errorf("invalid cookie httponly: %v", cookie.HttpOnly) 428 | } 429 | if cookie.Expires.IsZero() { 430 | t.Errorf("invalid cookie expires: %v", cookie.Expires) 431 | } 432 | if err := auth.ValidateAuthCookie(cookie); err != nil { 433 | t.Error(err) 434 | } 435 | 436 | // invalid cookie 437 | orig := cookie.Value 438 | cookie.Value = cookie.Value + "xxx" 439 | if err := auth.ValidateAuthCookie(cookie); err == nil { 440 | t.Error("should be invalid") 441 | } 442 | // restore 443 | cookie.Value = orig 444 | t.Log(cookie.Value) 445 | 446 | // expired 447 | time.Sleep(2 * time.Second) 448 | if err := auth.ValidateAuthCookie(cookie); err == nil { 449 | t.Error("should be expired") 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /cmd/mirage-ecs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "syscall" 12 | 13 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 14 | "gopkg.in/yaml.v2" 15 | ) 16 | 17 | var ( 18 | Version string 19 | buildDate string 20 | ) 21 | 22 | func main() { 23 | confFile := flag.String("conf", "", "specify config file or S3 URL") 24 | domain := flag.String("domain", ".local", "reverse proxy suffix") 25 | var showVersion, showConfig, localMode, compatV1 bool 26 | var defaultPort int 27 | var logFormat, logLevel string 28 | flag.BoolVar(&showVersion, "version", false, "show version") 29 | flag.BoolVar(&showVersion, "v", false, "show version") 30 | flag.BoolVar(&showConfig, "x", false, "show config") 31 | flag.BoolVar(&localMode, "local", false, "local mode (for development)") 32 | flag.BoolVar(&compatV1, "compat-v1", false, "compatibility mode for v1") 33 | flag.IntVar(&defaultPort, "default-port", 80, "default port number") 34 | flag.StringVar(&logFormat, "log-format", "text", "log format (text, json)") 35 | flag.StringVar(&logLevel, "log-level", "info", "log level (debug, info, warn, error)") 36 | flag.VisitAll(overrideWithEnv) 37 | flag.Parse() 38 | 39 | mirageecs.SetLogLevel(logLevel) 40 | 41 | if showVersion { 42 | fmt.Printf("mirage-ecs %s (%s)\n", Version, buildDate) 43 | return 44 | } 45 | 46 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 47 | defer stop() 48 | 49 | cfg, err := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{ 50 | Path: *confFile, 51 | LocalMode: localMode, 52 | Domain: *domain, 53 | DefaultPort: defaultPort, 54 | CompatV1: compatV1, 55 | LogFormat: logFormat, 56 | }) 57 | if err != nil { 58 | slog.Error(err.Error()) 59 | os.Exit(1) 60 | } 61 | if showConfig { 62 | yaml.NewEncoder(os.Stdout).Encode(cfg) 63 | return 64 | } 65 | mirageecs.Version = Version 66 | app := mirageecs.New(ctx, cfg) 67 | if err := app.Run(ctx); err != nil { 68 | slog.Error(err.Error()) 69 | os.Exit(1) 70 | } 71 | } 72 | 73 | func overrideWithEnv(f *flag.Flag) { 74 | name := strings.ToUpper(f.Name) 75 | name = strings.Replace(name, "-", "_", -1) 76 | if s := os.Getenv("MIRAGE_" + name); s != "" { 77 | f.Value.Set(s) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path" 15 | "path/filepath" 16 | "regexp" 17 | "strings" 18 | "time" 19 | 20 | "github.com/aws/aws-sdk-go-v2/aws" 21 | awsv2Config "github.com/aws/aws-sdk-go-v2/config" 22 | "github.com/aws/aws-sdk-go-v2/service/ecs" 23 | "github.com/aws/aws-sdk-go-v2/service/ecs/types" 24 | "github.com/aws/aws-sdk-go-v2/service/s3" 25 | metadata "github.com/brunoscheufler/aws-ecs-metadata-go" 26 | config "github.com/kayac/go-config" 27 | "github.com/labstack/echo/v4" 28 | ) 29 | 30 | var DefaultParameter = &Parameter{ 31 | Name: "branch", 32 | Env: "GIT_BRANCH", 33 | Rule: "", 34 | Required: true, 35 | Default: "", 36 | } 37 | 38 | type Config struct { 39 | Host Host `yaml:"host"` 40 | Listen Listen `yaml:"listen"` 41 | Network Network `yaml:"network"` 42 | HtmlDir string `yaml:"htmldir"` 43 | Parameter Parameters `yaml:"parameters"` 44 | ECS ECSCfg `yaml:"ecs"` 45 | Link Link `yaml:"link"` 46 | Auth *Auth `yaml:"auth"` 47 | Purge *Purge `yaml:"purge"` 48 | 49 | compatV1 bool 50 | localMode bool 51 | awscfg *aws.Config 52 | cleanups []func() error 53 | } 54 | 55 | type ECSCfg struct { 56 | Region string `yaml:"region"` 57 | Cluster string `yaml:"cluster"` 58 | CapacityProviderStrategy CapacityProviderStrategy `yaml:"capacity_provider_strategy"` 59 | LaunchType *string `yaml:"launch_type"` 60 | NetworkConfiguration *NetworkConfiguration `yaml:"network_configuration"` 61 | DefaultTaskDefinition string `yaml:"default_task_definition"` 62 | EnableExecuteCommand *bool `yaml:"enable_execute_command"` 63 | 64 | capacityProviderStrategy []types.CapacityProviderStrategyItem `yaml:"-"` 65 | networkConfiguration *types.NetworkConfiguration `yaml:"-"` 66 | } 67 | 68 | func (c ECSCfg) String() string { 69 | m := map[string]interface{}{ 70 | "region": c.Region, 71 | "cluster": c.Cluster, 72 | "capacity_provider_strategy": c.capacityProviderStrategy, 73 | "launch_type": c.LaunchType, 74 | "network_configuration": c.networkConfiguration, 75 | "default_task_definition": c.DefaultTaskDefinition, 76 | "enable_execute_command": c.EnableExecuteCommand, 77 | } 78 | b, _ := json.Marshal(m) 79 | return string(b) 80 | } 81 | 82 | func (c ECSCfg) validate() error { 83 | if c.Region == "" { 84 | return fmt.Errorf("region is required") 85 | } 86 | if c.Cluster == "" { 87 | return fmt.Errorf("cluster is required") 88 | } 89 | if c.LaunchType == nil && c.capacityProviderStrategy == nil { 90 | return fmt.Errorf("launch_type or capacity_provider_strategy is required") 91 | } 92 | if c.networkConfiguration == nil { 93 | return fmt.Errorf("network_configuration is required") 94 | } 95 | return nil 96 | } 97 | 98 | type CapacityProviderStrategy []*CapacityProviderStrategyItem 99 | 100 | func (s CapacityProviderStrategy) toSDK() []types.CapacityProviderStrategyItem { 101 | if len(s) == 0 { 102 | return nil 103 | } 104 | var items []types.CapacityProviderStrategyItem 105 | for _, item := range s { 106 | items = append(items, item.toSDK()) 107 | } 108 | return items 109 | } 110 | 111 | type CapacityProviderStrategyItem struct { 112 | CapacityProvider *string `yaml:"capacity_provider"` 113 | Weight int32 `yaml:"weight"` 114 | Base int32 `yaml:"base"` 115 | } 116 | 117 | func (i CapacityProviderStrategyItem) toSDK() types.CapacityProviderStrategyItem { 118 | return types.CapacityProviderStrategyItem{ 119 | CapacityProvider: i.CapacityProvider, 120 | Weight: i.Weight, 121 | Base: i.Base, 122 | } 123 | } 124 | 125 | type NetworkConfiguration struct { 126 | AwsVpcConfiguration *AwsVpcConfiguration `yaml:"awsvpc_configuration"` 127 | } 128 | 129 | func (c *NetworkConfiguration) toSDK() *types.NetworkConfiguration { 130 | if c == nil { 131 | return nil 132 | } 133 | return &types.NetworkConfiguration{ 134 | AwsvpcConfiguration: c.AwsVpcConfiguration.toSDK(), 135 | } 136 | } 137 | 138 | type AwsVpcConfiguration struct { 139 | AssignPublicIp string `yaml:"assign_public_ip"` 140 | SecurityGroups []string `yaml:"security_groups"` 141 | Subnets []string `yaml:"subnets"` 142 | } 143 | 144 | func (c *AwsVpcConfiguration) toSDK() *types.AwsVpcConfiguration { 145 | return &types.AwsVpcConfiguration{ 146 | AssignPublicIp: types.AssignPublicIp(c.AssignPublicIp), 147 | Subnets: c.Subnets, 148 | SecurityGroups: c.SecurityGroups, 149 | } 150 | } 151 | 152 | type Host struct { 153 | WebApi string `yaml:"webapi"` 154 | ReverseProxySuffix string `yaml:"reverse_proxy_suffix"` 155 | } 156 | 157 | type Link struct { 158 | HostedZoneID string `yaml:"hosted_zone_id"` 159 | DefaultTaskDefinitions []string `yaml:"default_task_definitions"` 160 | } 161 | 162 | type Listen struct { 163 | ForeignAddress string `yaml:"foreign_address,omitempty"` 164 | HTTP []PortMap `yaml:"http,omitempty"` 165 | HTTPS []PortMap `yaml:"https,omitempty"` 166 | } 167 | 168 | type PortMap struct { 169 | ListenPort int `yaml:"listen"` 170 | TargetPort int `yaml:"target"` 171 | RequireAuthCookie bool `yaml:"require_auth_cookie"` 172 | } 173 | 174 | type Parameter struct { 175 | Name string `yaml:"name"` 176 | Env string `yaml:"env"` 177 | Rule string `yaml:"rule"` 178 | Required bool `yaml:"required"` 179 | Regexp regexp.Regexp `yaml:"-"` 180 | Default string `yaml:"default"` 181 | Description string `yaml:"description"` 182 | Options []ParameterOption `yaml:"options"` 183 | } 184 | 185 | type ParameterOption struct { 186 | Label string `yaml:"label"` 187 | Value string `yaml:"value"` 188 | } 189 | 190 | type Parameters []*Parameter 191 | 192 | type ConfigParams struct { 193 | Path string 194 | Domain string 195 | LocalMode bool 196 | DefaultPort int 197 | CompatV1 bool 198 | LogFormat string 199 | } 200 | 201 | type Network struct { 202 | ProxyTimeout time.Duration `yaml:"proxy_timeout"` 203 | } 204 | 205 | const DefaultPort = 80 206 | const DefaultProxyTimeout = 0 207 | const AuthCookieName = "mirage-ecs-auth" 208 | const AuthCookieExpire = 24 * time.Hour 209 | 210 | func NewConfig(ctx context.Context, p *ConfigParams) (*Config, error) { 211 | domain := p.Domain 212 | if !strings.HasPrefix(domain, ".") { 213 | domain = "." + domain 214 | } 215 | if p.DefaultPort == 0 { 216 | p.DefaultPort = DefaultPort 217 | } 218 | // default config 219 | cfg := &Config{ 220 | Host: Host{ 221 | WebApi: "mirage" + domain, 222 | ReverseProxySuffix: domain, 223 | }, 224 | Listen: Listen{ 225 | ForeignAddress: "0.0.0.0", 226 | HTTP: []PortMap{ 227 | {ListenPort: p.DefaultPort, TargetPort: p.DefaultPort}, 228 | }, 229 | HTTPS: nil, 230 | }, 231 | Network: Network{ 232 | ProxyTimeout: DefaultProxyTimeout, 233 | }, 234 | HtmlDir: "./html", 235 | ECS: ECSCfg{ 236 | Region: os.Getenv("AWS_REGION"), 237 | }, 238 | Auth: nil, 239 | Purge: nil, 240 | 241 | localMode: p.LocalMode, 242 | compatV1: p.CompatV1, 243 | } 244 | opt := &slog.HandlerOptions{ 245 | Level: LogLevel, 246 | AddSource: true, 247 | } 248 | 249 | switch p.LogFormat { 250 | case "text", "": 251 | slog.SetDefault(slog.New(NewLogHandler(os.Stderr, opt))) 252 | case "json": 253 | slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, opt))) 254 | default: 255 | return nil, fmt.Errorf("invalid log format (text or json): %s", p.LogFormat) 256 | } 257 | 258 | if awscfg, err := awsv2Config.LoadDefaultConfig(ctx, awsv2Config.WithRegion(cfg.ECS.Region)); err != nil { 259 | return nil, err 260 | } else { 261 | cfg.awscfg = &awscfg 262 | } 263 | 264 | if p.Path == "" { 265 | slog.Info(f("no config file specified, using default config with domain suffix: %s", domain)) 266 | } else { 267 | var content []byte 268 | var err error 269 | if strings.HasPrefix(p.Path, "s3://") { 270 | content, err = loadFromS3(ctx, cfg.awscfg, p.Path) 271 | } else { 272 | content, err = loadFromFile(p.Path) 273 | } 274 | if err != nil { 275 | return nil, fmt.Errorf("cannot load config: %s: %w", p.Path, err) 276 | } 277 | slog.Info(f("loading config file: %s", p.Path)) 278 | if err := config.LoadWithEnvBytes(&cfg, content); err != nil { 279 | return nil, fmt.Errorf("cannot load config: %s: %w", p.Path, err) 280 | } 281 | } 282 | 283 | addDefaultParameter := true 284 | for _, v := range cfg.Parameter { 285 | if v.Name == DefaultParameter.Name { 286 | addDefaultParameter = false 287 | break 288 | } 289 | } 290 | if addDefaultParameter { 291 | cfg.Parameter = append(cfg.Parameter, DefaultParameter) 292 | } 293 | 294 | for _, v := range cfg.Parameter { 295 | if v.Rule != "" { 296 | paramRegex, err := regexp.Compile(v.Rule) 297 | if err != nil { 298 | return nil, fmt.Errorf("invalid parameter rule: %s: %w", v.Rule, err) 299 | } 300 | v.Regexp = *paramRegex 301 | } 302 | } 303 | 304 | if strings.HasPrefix(cfg.HtmlDir, "s3://") { 305 | if err := cfg.downloadHTMLFromS3(ctx); err != nil { 306 | return nil, err 307 | } 308 | } 309 | 310 | if cfg.localMode { 311 | slog.Info("local mode: setting host suffix to .localtest.me") 312 | cfg.Host.ReverseProxySuffix = ".localtest.me" 313 | cfg.Host.WebApi = "mirage.localtest.me" 314 | slog.Info(f("You can access to http://mirage.localtest.me:%d/", cfg.Listen.HTTP[0].ListenPort)) 315 | } 316 | 317 | cfg.ECS.capacityProviderStrategy = cfg.ECS.CapacityProviderStrategy.toSDK() 318 | cfg.ECS.networkConfiguration = cfg.ECS.NetworkConfiguration.toSDK() 319 | 320 | if err := cfg.fillECSDefaults(ctx); err != nil { 321 | slog.Warn(f("failed to fill ECS defaults: %s", err)) 322 | } 323 | 324 | if cfg.Purge != nil { 325 | if err := cfg.Purge.Validate(); err != nil { 326 | return nil, fmt.Errorf("invalid purge config: %w", err) 327 | } 328 | } 329 | return cfg, nil 330 | } 331 | 332 | func (c *Config) Cleanup() { 333 | for _, fn := range c.cleanups { 334 | if err := fn(); err != nil { 335 | slog.Warn(f("failed to cleanup %s", err)) 336 | } 337 | } 338 | } 339 | 340 | func (c *Config) NewTaskRunner() TaskRunner { 341 | if c.localMode { 342 | return NewLocalTaskRunner(c) 343 | } else { 344 | return NewECSTaskRunner(c) 345 | } 346 | } 347 | 348 | func (c *Config) fillECSDefaults(ctx context.Context) error { 349 | if c.localMode { 350 | slog.Info("ECS config is not used in local mode") 351 | return nil 352 | } 353 | defer func() { 354 | if err := c.ECS.validate(); err != nil { 355 | slog.Error(f("invalid ECS config: %s", c.ECS)) 356 | slog.Error(f("ECS config is invalid '%s', so you may not be able to launch ECS tasks", err)) 357 | } else { 358 | slog.Info(f("built ECS config: %s", c.ECS)) 359 | } 360 | }() 361 | if c.ECS.Region == "" { 362 | c.ECS.Region = os.Getenv("AWS_REGION") 363 | slog.Info(f("AWS_REGION is not set, using region=%s", c.ECS.Region)) 364 | } 365 | if c.ECS.LaunchType == nil && c.ECS.CapacityProviderStrategy == nil { 366 | launchType := "FARGATE" 367 | c.ECS.LaunchType = &launchType 368 | slog.Info(f("launch_type and capacity_provider_strategy are not set, using launch_type=%s", *c.ECS.LaunchType)) 369 | } 370 | if c.ECS.EnableExecuteCommand == nil { 371 | c.ECS.EnableExecuteCommand = aws.Bool(true) 372 | slog.Info(f("enable_execute_command is not set, using enable_execute_command=%t", *c.ECS.EnableExecuteCommand)) 373 | } 374 | 375 | meta, err := metadata.Get(ctx, &http.Client{}) 376 | if err != nil { 377 | return err 378 | /* 379 | for local debugging 380 | meta = &metadata.TaskMetadataV4{ 381 | Cluster: "your test cluster", 382 | TaskARN: "your test task arn running on the cluster", 383 | } 384 | */ 385 | } 386 | slog.Debug(f("task metadata: %v", meta)) 387 | var cluster, taskArn, service string 388 | switch m := meta.(type) { 389 | case *metadata.TaskMetadataV3: 390 | cluster = m.Cluster 391 | taskArn = m.TaskARN 392 | case *metadata.TaskMetadataV4: 393 | cluster = m.Cluster 394 | taskArn = m.TaskARN 395 | } 396 | if c.ECS.Cluster == "" && cluster != "" { 397 | slog.Info(f("ECS cluster is set from task metadata: %s", cluster)) 398 | c.ECS.Cluster = cluster 399 | } 400 | 401 | svc := ecs.NewFromConfig(*c.awscfg) 402 | if out, err := svc.DescribeTasks(ctx, &ecs.DescribeTasksInput{ 403 | Cluster: aws.String(cluster), 404 | Tasks: []string{taskArn}, 405 | }); err != nil { 406 | return err 407 | } else { 408 | if len(out.Tasks) == 0 { 409 | return fmt.Errorf("cannot find task: %s", taskArn) 410 | } 411 | group := aws.ToString(out.Tasks[0].Group) 412 | if strings.HasPrefix(group, "service:") { 413 | service = group[8:] 414 | } 415 | } 416 | if out, err := svc.DescribeServices(ctx, &ecs.DescribeServicesInput{ 417 | Cluster: aws.String(cluster), 418 | Services: []string{service}, 419 | }); err != nil { 420 | return err 421 | } else { 422 | if len(out.Services) == 0 { 423 | return fmt.Errorf("cannot find service: %s", service) 424 | } 425 | if c.ECS.networkConfiguration == nil { 426 | c.ECS.networkConfiguration = out.Services[0].NetworkConfiguration 427 | slog.Info(f("network_configuration is not set, using network_configuration=%v", c.ECS.networkConfiguration)) 428 | } 429 | } 430 | return nil 431 | } 432 | 433 | func (cfg *Config) AuthMiddlewareForWeb(next echo.HandlerFunc) echo.HandlerFunc { 434 | return func(c echo.Context) error { 435 | req := c.Request() 436 | ok, err := cfg.Auth.Do(req, c.Response(), 437 | cfg.Auth.ByToken, cfg.Auth.ByAmznOIDC, cfg.Auth.ByBasic, 438 | ) 439 | if err != nil { 440 | slog.Error(f("auth error: %s", err)) 441 | return echo.ErrInternalServerError 442 | } 443 | if !ok { 444 | slog.Warn("all auth methods failed") 445 | return echo.ErrUnauthorized 446 | } 447 | 448 | // check origin header 449 | if req.Method == http.MethodPost { 450 | origin := c.Request().Header.Get("Origin") 451 | if origin == "" { 452 | slog.Error("missing origin header") 453 | return echo.ErrBadRequest 454 | } 455 | u, err := url.Parse(origin) 456 | if err != nil { 457 | slog.Error(f("invalid origin header: %s", origin)) 458 | return echo.ErrBadRequest 459 | } 460 | host, _, err := net.SplitHostPort(u.Host) 461 | if err != nil { 462 | host = u.Host // missing port 463 | } 464 | if host != cfg.Host.WebApi { 465 | slog.Error(f("invalid origin host: %s", u.Host)) 466 | return echo.ErrBadRequest 467 | } 468 | } 469 | 470 | cookie, err := cfg.Auth.NewAuthCookie(AuthCookieExpire, cfg.Host.ReverseProxySuffix) 471 | if err != nil { 472 | slog.Error(f("failed to create auth cookie: %s", err)) 473 | return echo.ErrInternalServerError 474 | } 475 | if cookie.Value != "" { 476 | c.SetCookie(cookie) 477 | } 478 | return next(c) 479 | } 480 | } 481 | 482 | func (cfg *Config) AuthMiddlewareForAPI(next echo.HandlerFunc) echo.HandlerFunc { 483 | return func(c echo.Context) error { 484 | // API allows only token auth 485 | ok, err := cfg.Auth.Do(c.Request(), c.Response(), cfg.Auth.ByToken) 486 | if err != nil { 487 | slog.Error(f("auth error: %s", err)) 488 | return echo.ErrInternalServerError 489 | } 490 | if !ok { 491 | slog.Warn(f("all auth methods failed")) 492 | return echo.ErrUnauthorized 493 | } 494 | return next(c) 495 | } 496 | } 497 | 498 | func (cfg *Config) CompatMiddlewareForAPI(next echo.HandlerFunc) echo.HandlerFunc { 499 | return func(c echo.Context) error { 500 | req := c.Request() 501 | contentType := req.Header.Get("Content-Type") 502 | switch cfg.compatV1 { 503 | case true: 504 | // allows any content type 505 | case false: 506 | if req.Method == http.MethodPost && !strings.HasPrefix(contentType, "application/json") { 507 | slog.Error(f("invalid content type: %s for %s %s", contentType, req.Method, req.URL.Path)) 508 | return echo.ErrBadRequest 509 | } 510 | } 511 | return next(c) 512 | } 513 | } 514 | 515 | func loadFromFile(p string) ([]byte, error) { 516 | f, err := os.Open(p) 517 | if err != nil { 518 | return nil, err 519 | } 520 | defer f.Close() 521 | return io.ReadAll(f) 522 | } 523 | 524 | func loadFromS3(ctx context.Context, awscfg *aws.Config, u string) ([]byte, error) { 525 | svc := s3.NewFromConfig(*awscfg) 526 | parsed, err := url.Parse(u) 527 | if err != nil { 528 | return nil, err 529 | } 530 | if parsed.Scheme != "s3" { 531 | return nil, fmt.Errorf("invalid scheme: %s", parsed.Scheme) 532 | } 533 | bucket := parsed.Host 534 | key := strings.TrimPrefix(parsed.Path, "/") 535 | out, err := svc.GetObject(ctx, &s3.GetObjectInput{ 536 | Bucket: aws.String(bucket), 537 | Key: aws.String(key), 538 | }) 539 | if err != nil { 540 | return nil, err 541 | } 542 | defer out.Body.Close() 543 | return io.ReadAll(out.Body) 544 | } 545 | 546 | func (c *Config) downloadHTMLFromS3(ctx context.Context) error { 547 | slog.Info(f("downloading html files from %s", c.HtmlDir)) 548 | tmpdir, err := os.MkdirTemp("", "mirage-ecs-htmldir-") 549 | if err != nil { 550 | return err 551 | } 552 | svc := s3.NewFromConfig(*c.awscfg) 553 | parsed, err := url.Parse(c.HtmlDir) 554 | if err != nil { 555 | return err 556 | } 557 | if parsed.Scheme != "s3" { 558 | return fmt.Errorf("invalid scheme: %s", parsed.Scheme) 559 | } 560 | bucket := parsed.Host 561 | keyPrefix := strings.TrimPrefix(parsed.Path, "/") 562 | if !strings.HasSuffix(keyPrefix, "/") { 563 | keyPrefix += "/" 564 | } 565 | slog.Debug(f("bucket: %s keyPrefix: %s", bucket, keyPrefix)) 566 | res, err := svc.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 567 | Bucket: aws.String(bucket), 568 | Prefix: aws.String(keyPrefix), 569 | Delimiter: aws.String("/"), 570 | MaxKeys: 100, // sufficient for html template files 571 | }) 572 | if err != nil { 573 | return err 574 | } 575 | if len(res.Contents) == 0 { 576 | return fmt.Errorf("no objects found in %s", c.HtmlDir) 577 | } 578 | files := 0 579 | for _, obj := range res.Contents { 580 | slog.Info(f("downloading %s", aws.ToString(obj.Key))) 581 | r, err := svc.GetObject(ctx, &s3.GetObjectInput{ 582 | Bucket: aws.String(bucket), 583 | Key: obj.Key, 584 | }) 585 | if err != nil { 586 | return err 587 | } 588 | defer r.Body.Close() 589 | filename := path.Base(aws.ToString(obj.Key)) 590 | file := filepath.Join(tmpdir, filename) 591 | if size, err := copyToFile(r.Body, file); err != nil { 592 | return err 593 | } else { 594 | files++ 595 | slog.Info(f("downloaded %s (%d bytes)", file, size)) 596 | } 597 | } 598 | slog.Info(f("downloaded %d files from %s", files, c.HtmlDir)) 599 | c.HtmlDir = tmpdir 600 | c.cleanups = append(c.cleanups, func() error { 601 | slog.Info(f("removing %s", tmpdir)) 602 | return os.RemoveAll(tmpdir) 603 | }) 604 | slog.Info(f("setting html dir: %s", c.HtmlDir)) 605 | return nil 606 | } 607 | 608 | func copyToFile(src io.Reader, dst string) (int64, error) { 609 | f, err := os.Create(dst) 610 | if err != nil { 611 | return 0, err 612 | } 613 | defer f.Close() 614 | return io.Copy(f, src) 615 | } 616 | 617 | func (cfg *Config) ValidateOriginMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 618 | return func(c echo.Context) error { 619 | return next(c) 620 | } 621 | } 622 | 623 | func (cfg *Config) EncodeSubdomain(subdomain string) string { 624 | if cfg.compatV1 { 625 | return base64.URLEncoding.EncodeToString([]byte(subdomain)) 626 | } else { 627 | return subdomain 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /config_auth_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | func TestAuthMiddleware(t *testing.T) { 15 | config := &mirageecs.Config{ 16 | Host: mirageecs.Host{ 17 | WebApi: "mirage.localtest.me", 18 | ReverseProxySuffix: ".localtest.me", 19 | }, 20 | Auth: &mirageecs.Auth{ 21 | CookieSecret: "cookie-secret", 22 | Token: &mirageecs.AuthMethodToken{ 23 | Header: "x-mirage-token", 24 | Token: "mytoken", 25 | }, 26 | Basic: &mirageecs.AuthMethodBasic{ 27 | Username: "user", 28 | Password: "pass", 29 | }, 30 | }, 31 | } 32 | 33 | var validCookie *http.Cookie 34 | validateCookie := func(cookies []*http.Cookie) error { 35 | for _, c := range cookies { 36 | if c.Name == mirageecs.AuthCookieName && c.HttpOnly && c.Secure && c.Domain == "localtest.me" { 37 | validCookie = c 38 | return nil 39 | } 40 | } 41 | return fmt.Errorf("cookie is not set correctly %v", cookies) 42 | } 43 | 44 | cases := []struct { 45 | Name string 46 | Request func() *http.Request 47 | ExpectStatus int 48 | Expect func(*http.Response) error 49 | BodyContains string 50 | }{ 51 | { 52 | Name: "Token matches", 53 | Request: func() *http.Request { 54 | req := httptest.NewRequest(http.MethodGet, "/", nil) 55 | req.Header.Set("x-mirage-token", "mytoken") 56 | return req 57 | }, 58 | ExpectStatus: 200, 59 | Expect: func(res *http.Response) error { 60 | if err := validateCookie(res.Cookies()); err != nil { 61 | return err 62 | } 63 | return nil 64 | }, 65 | BodyContains: "ok", 66 | }, 67 | { 68 | Name: "Token does not match", 69 | Request: func() *http.Request { 70 | req := httptest.NewRequest(http.MethodGet, "/", nil) 71 | req.Header.Set("x-mirage-token", "othertoken") 72 | return req 73 | }, 74 | ExpectStatus: 401, 75 | Expect: func(res *http.Response) error { 76 | if res.Header.Get("WWW-Authenticate") != `Basic realm="Restricted"` { 77 | return fmt.Errorf("WWW-Authenticate header is not set") 78 | } 79 | if len(res.Cookies()) != 0 { 80 | return fmt.Errorf("cookie should not be set %v", res.Cookies()) 81 | } 82 | return nil 83 | }, 84 | }, 85 | { 86 | Name: "Basic matches", 87 | Request: func() *http.Request { 88 | req := httptest.NewRequest(http.MethodGet, "/", nil) 89 | req.SetBasicAuth("user", "pass") 90 | return req 91 | }, 92 | ExpectStatus: 200, 93 | Expect: func(res *http.Response) error { 94 | if err := validateCookie(res.Cookies()); err != nil { 95 | return err 96 | } 97 | return nil 98 | }, 99 | BodyContains: "ok", 100 | }, 101 | { 102 | Name: "Basic does not match", 103 | Request: func() *http.Request { 104 | req := httptest.NewRequest(http.MethodGet, "/", nil) 105 | req.SetBasicAuth("userx", "pass") 106 | return req 107 | }, 108 | ExpectStatus: 401, 109 | Expect: func(res *http.Response) error { 110 | if len(res.Cookies()) != 0 { 111 | return fmt.Errorf("cookie should not be set %v", res.Cookies()) 112 | } 113 | return nil 114 | }, 115 | }, 116 | { 117 | Name: "token matches, basic does not match", 118 | Request: func() *http.Request { 119 | req := httptest.NewRequest(http.MethodGet, "/", nil) 120 | req.Header.Set("x-mirage-token", "mytoken") 121 | req.SetBasicAuth("userx", "pass") 122 | return req 123 | }, 124 | ExpectStatus: 200, 125 | Expect: func(res *http.Response) error { 126 | if err := validateCookie(res.Cookies()); err != nil { 127 | return err 128 | } 129 | return nil 130 | }, 131 | BodyContains: "ok", 132 | }, 133 | { 134 | Name: "/api/* don't allow basic auth", 135 | Request: func() *http.Request { 136 | req := httptest.NewRequest(http.MethodGet, "/api/list", nil) 137 | req.SetBasicAuth("user", "pass") 138 | return req 139 | }, 140 | ExpectStatus: 401, 141 | Expect: func(res *http.Response) error { 142 | if len(res.Cookies()) != 0 { 143 | return fmt.Errorf("cookie should not be set %v", res.Cookies()) 144 | } 145 | return nil 146 | }, 147 | }, 148 | { 149 | Name: "POST /launch requries origin header", 150 | Request: func() *http.Request { 151 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 152 | req.SetBasicAuth("user", "pass") 153 | return req 154 | }, 155 | ExpectStatus: 400, 156 | }, 157 | { 158 | Name: "POST /launch succeeds with valid origin header with basic auth", 159 | Request: func() *http.Request { 160 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 161 | req.SetBasicAuth("user", "pass") 162 | req.Header.Set("Origin", "https://mirage.localtest.me:8000") 163 | return req 164 | }, 165 | ExpectStatus: 200, 166 | BodyContains: "launched", 167 | }, 168 | { 169 | Name: "POST /launch succeeds with valid origin header(without port) with basic auth", 170 | Request: func() *http.Request { 171 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 172 | req.SetBasicAuth("user", "pass") 173 | req.Header.Set("Origin", "https://mirage.localtest.me") 174 | return req 175 | }, 176 | ExpectStatus: 200, 177 | BodyContains: "launched", 178 | }, 179 | { 180 | Name: "POST /launch fails with valid origin header with valid cookie only", 181 | Request: func() *http.Request { 182 | req := httptest.NewRequest(http.MethodPost, "/launch", nil) 183 | req.AddCookie(validCookie) 184 | req.Header.Set("Origin", "https://mirage.localtest.me:8000") 185 | return req 186 | }, 187 | ExpectStatus: 401, 188 | }, 189 | { 190 | Name: "webapi dosen't allow cookie auth", 191 | Request: func() *http.Request { 192 | req := httptest.NewRequest(http.MethodGet, "/api/list", nil) 193 | req.AddCookie(validCookie) 194 | return req 195 | }, 196 | ExpectStatus: 401, 197 | }, 198 | } 199 | 200 | for _, tc := range cases { 201 | t.Run(tc.Name, func(t *testing.T) { 202 | e := echo.New() 203 | handler := func(c echo.Context) error { 204 | p := c.Request().URL.Path 205 | switch p { 206 | case "/": 207 | return c.String(http.StatusOK, "ok") 208 | case "/api/list": 209 | return c.JSON(http.StatusOK, []int{}) 210 | case "/launch": 211 | return c.String(http.StatusOK, "launched") 212 | default: 213 | return c.String(http.StatusNotFound, "not found") 214 | } 215 | } 216 | mdForAPI := config.AuthMiddlewareForAPI(handler) 217 | mdForWeb := config.AuthMiddlewareForWeb(handler) 218 | middleware := func(c echo.Context) error { 219 | if strings.HasPrefix(c.Request().URL.Path, "/api/") { 220 | return mdForAPI(c) 221 | } else { 222 | return mdForWeb(c) 223 | } 224 | } 225 | rec := httptest.NewRecorder() 226 | c := e.NewContext(tc.Request(), rec) 227 | err := middleware(c) 228 | if err == nil { 229 | if rec.Code != tc.ExpectStatus { 230 | t.Errorf("unexpected status code: %d", rec.Code) 231 | } 232 | } else { 233 | if code := err.(*echo.HTTPError).Code; code != tc.ExpectStatus { 234 | t.Errorf("unexpected status code: %d", code) 235 | } 236 | } 237 | if tc.Expect != nil { 238 | if err := tc.Expect(rec.Result()); err != nil { 239 | t.Error(err) 240 | } 241 | } 242 | if tc.BodyContains != "" { 243 | if !strings.Contains(rec.Body.String(), tc.BodyContains) { 244 | t.Errorf("body does not contain %s got: %s", tc.BodyContains, rec.Body.String()) 245 | } 246 | } 247 | }) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /config_sample.yml: -------------------------------------------------------------------------------- 1 | host: 2 | # web api host 3 | # you can use API and Web interface through this host 4 | # webapi: docker.dev.example.net 5 | webapi: localhost 6 | 7 | listen: 8 | # listen address 9 | # default is only listen from localhost 10 | foreign_address: 127.0.0.1 11 | 12 | # listen port and reverse proxy port 13 | http: 14 | # listen 8080 and transport to container's 5000 port 15 | - listen: 8080 16 | target: 5000 17 | 18 | htmldir: ./html 19 | 20 | parameters: 21 | - name: branch 22 | env: GIT_BRANCH 23 | rule: "" 24 | required: true 25 | # add your custom parameters here! 26 | # name is parameter name (passed by HTTP parameter) 27 | # env is environment variable for docker container 28 | # rule is constraint of value using regexp. 29 | # required means required or optional parameter (boolean value) 30 | 31 | ecs: 32 | region: ap-northeast-1 33 | cluster: mirage 34 | launch_type: FARGATE 35 | network_configuration: 36 | awsvpc_configuration: 37 | subnets: 38 | - '{{ env "SUBNET_A" "subnet-aaaaaa" }}' 39 | - '{{ env "SUBNET_B" "subnet-bbbbbb" }}' 40 | security_groups: 41 | - '{{ env "SECURITY_GROUP_1" "sg-111111" }}' 42 | assign_public_ip: ENABLED 43 | default_task_definition: '{{ env "DEFAULT_TASKDEF" "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/myapp" }}' 44 | # # enable link feature 45 | # link: 46 | # hosted_zone_id: '{{ env "LINK_ZONE_ID" "Z00000000000000000000" }}' 47 | # # overwrite ecs.default_task_definition 48 | # default_task_definitions: 49 | # - '{{ env "DEFAULT_TASKDEF" "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/myapp" }}' 50 | # - '{{ env "DEFAULT_TASKDEF_LINK" "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/myapp-link" }}' 51 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | 9 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 10 | ) 11 | 12 | func TestNewConfig(t *testing.T) { 13 | f, err := ioutil.TempFile("", "") 14 | if err != nil { 15 | t.Error(err) 16 | } 17 | defer func() { 18 | f.Close() 19 | os.Remove(f.Name()) 20 | }() 21 | 22 | data := `--- 23 | host: 24 | webapi: localhost 25 | reverse_proxy_suffix: .dev.example.net 26 | listen: 27 | foreign_address: 127.0.0.1 28 | http: 29 | - listen: 8080 30 | target: 5000 31 | ecs: 32 | region: ap-northeast-1 33 | cluster: test-cluster 34 | default_task_definition: test-task-definition 35 | capacity_provider_strategy: 36 | - capacity_provider: test-strategy 37 | base: 1 38 | weight: 1 39 | enable_execute_command: true 40 | network_configuration: 41 | awsvpc_configuration: 42 | subnets: 43 | - subnet-aaaa 44 | - subnet-bbbb 45 | - subnet-cccc 46 | security_groups: 47 | - sg-gggg 48 | assign_public_ip: ENABLED 49 | 50 | htmldir: ./html 51 | parameters: 52 | - name: branch 53 | env: GIT_BRANCH 54 | rule: "[0-9a-z-]{32}" 55 | required: true 56 | - name: nick 57 | env: NICK 58 | rule: "[0-9A-Za-z]{10}" 59 | required: false 60 | 61 | link: 62 | hosted_zone_id: Z00000000000000000000 63 | default_task_definitions: 64 | - test-task-definition 65 | - test-task-definition-link 66 | ` 67 | 68 | if err := ioutil.WriteFile(f.Name(), []byte(data), 0644); err != nil { 69 | t.Error(err) 70 | } 71 | ctx := context.Background() 72 | cfg, err := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{Path: f.Name()}) 73 | if err != nil { 74 | t.Error(err) 75 | } 76 | 77 | if cfg.Parameter[0].Name != "branch" { 78 | t.Error("could not parse parameter") 79 | } 80 | 81 | if cfg.Parameter[1].Env != "NICK" { 82 | t.Error("could not parse parameter") 83 | } 84 | 85 | if cfg.Parameter[0].Required != true { 86 | t.Error("could not parse parameter") 87 | } 88 | 89 | if cfg.ECS.Region != "ap-northeast-1" { 90 | t.Error("could not parse region") 91 | } 92 | if cfg.ECS.Cluster != "test-cluster" { 93 | t.Error("could not parse cluster") 94 | } 95 | if cfg.ECS.DefaultTaskDefinition != "test-task-definition" { 96 | t.Error("could not parse default_task_definition") 97 | } 98 | provider := cfg.ECS.CapacityProviderStrategy[0] 99 | if *provider.CapacityProvider != "test-strategy" { 100 | t.Error("could not parse capacity provider strategy") 101 | } 102 | if provider.Base != 1 { 103 | t.Error("could not parse capacity provider strategy") 104 | } 105 | nc := cfg.ECS.NetworkConfiguration 106 | if nc.AwsVpcConfiguration.AssignPublicIp != "ENABLED" { 107 | t.Error("could not parse network configuration") 108 | } 109 | if nc.AwsVpcConfiguration.SecurityGroups[0] != "sg-gggg" { 110 | t.Error("could not parse network configuration") 111 | } 112 | if nc.AwsVpcConfiguration.Subnets[0] != "subnet-aaaa" { 113 | t.Error("could not parse network configuration") 114 | } 115 | if !*cfg.ECS.EnableExecuteCommand { 116 | t.Error("could not parse enable execute command") 117 | } 118 | if cfg.Link.HostedZoneID != "Z00000000000000000000" { 119 | t.Error("could not parse link hosted zone") 120 | } 121 | if cfg.Link.DefaultTaskDefinitions[0] != "test-task-definition" { 122 | t.Error("could not parse link default task definitions") 123 | } 124 | if cfg.Link.DefaultTaskDefinitions[1] != "test-task-definition-link" { 125 | t.Error("could not parse link default task definitions") 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/facf1b9d27f138f73fa34445ae8743532dc56c28/data/.gitkeep -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS builder 2 | 3 | ADD . /stash/src/github.com/acidlemon/mirage-ecs 4 | WORKDIR /stash/src/github.com/acidlemon/mirage-ecs 5 | ENV GOPATH=/stash 6 | 7 | RUN make clean && make && mv mirage-ecs /stash/ 8 | RUN cp -a html /stash/ 9 | RUN cp docker/example-config.yml /stash/ 10 | 11 | FROM debian:bookworm-slim 12 | 13 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* 14 | RUN mkdir -p /opt/mirage/html 15 | COPY --from=builder /stash/mirage-ecs /opt/mirage/ 16 | COPY --from=builder /stash/example-config.yml /opt/mirage/ 17 | COPY --from=builder /stash/html/* /opt/mirage/html/ 18 | WORKDIR /opt/mirage 19 | ENV MIRAGE_LOG_LEVEL info 20 | ENV MIRAGE_CONF "" 21 | RUN /opt/mirage/mirage-ecs -version 22 | ENTRYPOINT ["/opt/mirage/mirage-ecs"] 23 | -------------------------------------------------------------------------------- /docker/example-config.yml: -------------------------------------------------------------------------------- 1 | host: 2 | webapi: '{{ env "MIRAGE_WEBAPI_HOST" "localhost" }}' 3 | reverse_proxy_suffix: '{{ env "MIRAGE_REVERSEPROXY_SUFFIX" ".dev.example.net" }}' 4 | 5 | listen: 6 | foreign_address: 0.0.0.0 7 | 8 | http: 9 | - listen: 80 10 | target: 80 11 | 12 | htmldir: ./html 13 | parameters: 14 | - name: branch 15 | env: GIT_BRANCH 16 | rule: "" 17 | required: true 18 | 19 | ecs: 20 | region: '{{ env "AWS_REGION" "us-east-1" }}' 21 | cluster: '{{ env "MIRAGE_ECS_CLUSTER" "default" }}' 22 | launch_type: '{{ env "MIRAGE_ECS_LAUNCH_TYPE" "FARGATE" }}' 23 | default_task_definition: '{{ env "MIRAGE_DEFAULT_TASKDEF" "myapp" }}' 24 | network_configuration: 25 | awsvpc_configuration: 26 | subnets: 27 | - '{{ env "MIRAGE_SUBNET_1" "subnet-aaaaaa" }}' 28 | - '{{ env "MIRAGE_SUBNET_2" "subnet-bbbbbb" }}' 29 | security_groups: 30 | - '{{ env "MIRAGE_SECURITY_GROUP" "sg-111111" }}' 31 | assign_public_ip: '{{ env "MIRAGE_ECS_ASSIGN_PUBLIC_IP" "ENABLED" }}' 32 | -------------------------------------------------------------------------------- /docs/mirage-ecs-launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/facf1b9d27f138f73fa34445ae8743532dc56c28/docs/mirage-ecs-launcher.png -------------------------------------------------------------------------------- /docs/mirage-ecs-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/facf1b9d27f138f73fa34445ae8743532dc56c28/docs/mirage-ecs-list.png -------------------------------------------------------------------------------- /e2e_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "testing" 12 | 13 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 14 | ) 15 | 16 | var e2eRequestsForm = map[string]string{ 17 | "/api/launch": url.Values{ 18 | "subdomain": []string{"mytask"}, 19 | "taskdef": []string{"dummy"}, 20 | "branch": []string{"develop"}, 21 | "env": []string{"test"}, 22 | }.Encode(), 23 | "/api/purge": url.Values{ 24 | "duration": []string{"300"}, 25 | }.Encode(), 26 | "/api/terminate": url.Values{ 27 | "subdomain": []string{"mytask"}, 28 | }.Encode(), 29 | } 30 | 31 | var e2eRequestsJSON = map[string]string{ 32 | "/api/launch": `{"subdomain":"mytask","taskdef":["dummy"],"branch":"develop","parameters":{"env":"test"}}`, 33 | "/api/purge": `{"duration":"300"}`, 34 | "/api/terminate": `{"subdomain":"mytask"}`, 35 | } 36 | 37 | func TestE2EAPI(t *testing.T) { 38 | t.Run("form v1", func(t *testing.T) { 39 | testE2EAPI(t, e2eRequestsForm, "application/x-www-form-urlencoded", true) 40 | }) 41 | t.Run("json v1", func(t *testing.T) { 42 | testE2EAPI(t, e2eRequestsJSON, "application/json", true) 43 | }) 44 | 45 | t.Run("json v2", func(t *testing.T) { 46 | testE2EAPI(t, e2eRequestsJSON, "application/json", false) 47 | }) 48 | } 49 | 50 | func testE2EAPI(t *testing.T, reqs map[string]string, contentType string, compatV1 bool) { 51 | ctx := context.Background() 52 | cfg, err := mirageecs.NewConfig(ctx, &mirageecs.ConfigParams{ 53 | LocalMode: true, 54 | Domain: "localtest.me", 55 | CompatV1: compatV1, 56 | }) 57 | cfg.Parameter = append(cfg.Parameter, &mirageecs.Parameter{ 58 | Name: "env", 59 | Env: "ENV", 60 | Required: true, 61 | }) 62 | 63 | if err != nil { 64 | t.Error(err) 65 | } 66 | m := mirageecs.New(context.Background(), cfg) 67 | ts := httptest.NewServer(m.WebApi) 68 | defer ts.Close() 69 | client := ts.Client() 70 | 71 | t.Run("/api/list at first", func(t *testing.T) { 72 | res, err := client.Get(ts.URL + "/api/list") 73 | if err != nil { 74 | t.Error(err) 75 | } 76 | defer res.Body.Close() 77 | if res.StatusCode != 200 { 78 | t.Errorf("status code should be 200: %d", res.StatusCode) 79 | } 80 | var r mirageecs.APIListResponse 81 | json.NewDecoder(res.Body).Decode(&r) 82 | if len(r.Result) != 0 { 83 | t.Errorf("result should be empty %#v", r) 84 | } 85 | }) 86 | 87 | t.Run("/api/launch", func(t *testing.T) { 88 | req, _ := http.NewRequest("POST", ts.URL+"/api/launch", strings.NewReader(reqs["/api/launch"])) 89 | req.Header.Set("Content-Type", contentType) 90 | res, err := client.Do(req) 91 | if err != nil { 92 | t.Error(err) 93 | } 94 | defer res.Body.Close() 95 | if res.StatusCode != 200 { 96 | body, _ := io.ReadAll(res.Body) 97 | t.Errorf("status code should be 200: %d", res.StatusCode) 98 | t.Errorf("body: %s", body) 99 | return 100 | } 101 | var r mirageecs.APICommonResponse 102 | json.NewDecoder(res.Body).Decode(&r) 103 | if r.Result != "ok" { 104 | t.Errorf("result should be ok %#v", r) 105 | } 106 | }) 107 | 108 | t.Run("/api/list after launched", func(t *testing.T) { 109 | res, err := client.Get(ts.URL + "/api/list") 110 | if err != nil { 111 | t.Error(err) 112 | } 113 | defer res.Body.Close() 114 | if res.StatusCode != 200 { 115 | t.Errorf("status code should be 200: %d", res.StatusCode) 116 | } 117 | var r mirageecs.APIListResponse 118 | json.NewDecoder(res.Body).Decode(&r) 119 | if len(r.Result) != 1 { 120 | t.Errorf("result should be empty %#v", r) 121 | } 122 | if r.Result[0].SubDomain != "mytask" { 123 | t.Errorf("subdomain should be mytask %#v", r) 124 | } 125 | if r.Result[0].TaskDef != "dummy" { 126 | t.Errorf("taskdef should be dummy %#v", r) 127 | } 128 | if r.Result[0].GitBranch != "develop" { 129 | t.Errorf("branch should be develop %#v", r) 130 | } 131 | }) 132 | 133 | t.Run("/api/access", func(t *testing.T) { 134 | res, err := client.Get(ts.URL + "/api/access?subdomain=mytask&duration=300") 135 | if err != nil { 136 | t.Error(err) 137 | } 138 | defer res.Body.Close() 139 | if res.StatusCode != 200 { 140 | t.Errorf("status code should be 200: %d", res.StatusCode) 141 | } 142 | var r mirageecs.APIAccessResponse 143 | json.NewDecoder(res.Body).Decode(&r) 144 | if r.Result != "ok" { 145 | t.Errorf("result should be ok %#v", r) 146 | } 147 | if r.Duration != 300 { 148 | t.Errorf("duration should be 300 %#v", r) 149 | } 150 | }) 151 | 152 | t.Run("/api/purge", func(t *testing.T) { 153 | req, _ := http.NewRequest("POST", ts.URL+"/api/purge", strings.NewReader(reqs["/api/purge"])) 154 | req.Header.Set("Content-Type", contentType) 155 | res, err := client.Do(req) 156 | if err != nil { 157 | t.Error(err) 158 | } 159 | defer res.Body.Close() 160 | if res.StatusCode != 200 { 161 | body, _ := io.ReadAll(res.Body) 162 | t.Errorf("status code should be 200: %d", res.StatusCode) 163 | t.Errorf("body: %s", body) 164 | return 165 | } 166 | var r mirageecs.APICommonResponse 167 | json.NewDecoder(res.Body).Decode(&r) 168 | if r.Result != "accepted" { 169 | t.Errorf("result should be ok %#v", r) 170 | } 171 | }) 172 | 173 | t.Run("/api/terminate", func(t *testing.T) { 174 | req, _ := http.NewRequest("POST", ts.URL+"/api/terminate", strings.NewReader(reqs["/api/terminate"])) 175 | req.Header.Set("Content-Type", contentType) 176 | res, err := client.Do(req) 177 | if err != nil { 178 | t.Error(err) 179 | } 180 | defer res.Body.Close() 181 | if res.StatusCode != 200 { 182 | body, _ := io.ReadAll(res.Body) 183 | t.Errorf("status code should be 200: %d", res.StatusCode) 184 | t.Errorf("body: %s", body) 185 | return 186 | } 187 | var r mirageecs.APICommonResponse 188 | json.NewDecoder(res.Body).Decode(&r) 189 | if r.Result != "ok" { 190 | t.Errorf("result should be ok %#v", r) 191 | } 192 | }) 193 | 194 | t.Run("/api/list after terminate", func(t *testing.T) { 195 | res, err := client.Get(ts.URL + "/api/list") 196 | if err != nil { 197 | t.Error(err) 198 | } 199 | defer res.Body.Close() 200 | if res.StatusCode != 200 { 201 | t.Errorf("status code should be 200: %d", res.StatusCode) 202 | } 203 | var r mirageecs.APIListResponse 204 | json.NewDecoder(res.Body).Decode(&r) 205 | if len(r.Result) != 0 { 206 | t.Errorf("result should be empty %#v", r) 207 | } 208 | }) 209 | 210 | t.Run("/api/launch with form", func(t *testing.T) { 211 | req, _ := http.NewRequest("POST", ts.URL+"/api/launch", strings.NewReader(e2eRequestsForm["/api/launch"])) 212 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 213 | res, err := client.Do(req) 214 | if err != nil { 215 | t.Error(err) 216 | } 217 | defer res.Body.Close() 218 | expectStatus := 400 // v2 219 | if compatV1 { 220 | expectStatus = 200 // v1 221 | } 222 | if res.StatusCode != expectStatus { 223 | t.Errorf("status code should be %d: %d", expectStatus, res.StatusCode) 224 | } 225 | }) 226 | } 227 | -------------------------------------------------------------------------------- /ecs-task-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "cpu": "256", 3 | "memory": "512", 4 | "containerDefinitions": [ 5 | { 6 | "name": "mirage-ecs", 7 | "image": "ghcr.io/acidlemon/mirage-ecs:v2.0.0", 8 | "portMappings": [ 9 | { 10 | "containerPort": 80, 11 | "hostPort": 80, 12 | "protocol": "tcp" 13 | } 14 | ], 15 | "essential": true, 16 | "environment": [ 17 | { 18 | "name": "MIRAGE_DOMAIN", 19 | "value": ".dev.example.net" 20 | }, 21 | { 22 | "name": "MIRAGE_LOG_LEVEL", 23 | "value": "info" 24 | } 25 | ], 26 | "logConfiguration": { 27 | "logDriver": "awslogs", 28 | "options": { 29 | "awslogs-create-group": "true", 30 | "awslogs-group": "/ecs/mirage-ecs", 31 | "awslogs-region": "ap-northeast-1", 32 | "awslogs-stream-prefix": "ecs" 33 | } 34 | } 35 | } 36 | ], 37 | "family": "mirage-ecs", 38 | "taskRoleArn": "arn:aws:iam::123456789012:role/ecs-task", 39 | "executionRoleArn": "arn:aws:iam::123456789012:role/ecs-task-execution", 40 | "networkMode": "awsvpc", 41 | "requiresCompatibilities": [ 42 | "EC2", 43 | "FARGATE" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /ecs.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "log/slog" 8 | "sort" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | ttlcache "github.com/ReneKroon/ttlcache/v2" 14 | "github.com/fujiwara/tracer" 15 | "github.com/samber/lo" 16 | 17 | "github.com/aws/aws-sdk-go-v2/aws" 18 | cw "github.com/aws/aws-sdk-go-v2/service/cloudwatch" 19 | cwTypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" 20 | cwlogs "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" 21 | "github.com/aws/aws-sdk-go-v2/service/ecs" 22 | "github.com/aws/aws-sdk-go-v2/service/ecs/types" 23 | 24 | "golang.org/x/sync/errgroup" 25 | ) 26 | 27 | var taskDefinitionCache = ttlcache.NewCache() // no need to expire because taskdef is immutable. 28 | 29 | type Information struct { 30 | ID string `json:"id"` 31 | ShortID string `json:"short_id"` 32 | SubDomain string `json:"subdomain"` 33 | GitBranch string `json:"branch"` 34 | TaskDef string `json:"taskdef"` 35 | IPAddress string `json:"ipaddress"` 36 | Created time.Time `json:"created"` 37 | LastStatus string `json:"last_status"` 38 | PortMap map[string]int `json:"port_map"` 39 | Env map[string]string `json:"env"` 40 | Tags []types.Tag `json:"tags"` 41 | 42 | task *types.Task 43 | } 44 | 45 | func (info Information) ShouldBePurged(p *PurgeParams) bool { 46 | if info.LastStatus != statusRunning { 47 | slog.Info(f("skip not running task: %s subdomain: %s", info.LastStatus, info.SubDomain)) 48 | return false 49 | } 50 | if _, ok := p.excludesMap[info.SubDomain]; ok { 51 | slog.Info(f("skip exclude subdomain: %s", info.SubDomain)) 52 | return false 53 | } 54 | for _, t := range info.Tags { 55 | k, v := aws.ToString(t.Key), aws.ToString(t.Value) 56 | if ev, ok := p.excludeTagsMap[k]; ok && ev == v { 57 | slog.Info(f("skip exclude tag: %s=%s subdomain: %s", k, v, info.SubDomain)) 58 | return false 59 | } 60 | } 61 | if p.ExcludeRegexp != nil && p.ExcludeRegexp.MatchString(info.SubDomain) { 62 | slog.Info(f("skip exclude regexp: %s subdomain: %s", p.ExcludeRegexp.String(), info.SubDomain)) 63 | return false 64 | } 65 | 66 | begin := time.Now().Add(-p.Duration) 67 | if info.Created.After(begin) { 68 | slog.Info(f("skip recent created: %s subdomain: %s", info.Created.Format(time.RFC3339), info.SubDomain)) 69 | return false 70 | } 71 | return true 72 | } 73 | 74 | type TaskParameter map[string]string 75 | 76 | func (p TaskParameter) ToECSKeyValuePairs(subdomain string, configParams Parameters, enc func(string) string) []types.KeyValuePair { 77 | kvp := make([]types.KeyValuePair, 0, len(p)+2) 78 | kvp = append(kvp, 79 | types.KeyValuePair{ 80 | Name: aws.String(EnvSubdomain), 81 | Value: aws.String(enc(subdomain)), 82 | }, 83 | types.KeyValuePair{ 84 | Name: aws.String(EnvSubdomainRaw), 85 | Value: aws.String(subdomain), 86 | }, 87 | ) 88 | for _, v := range configParams { 89 | v := v 90 | if p[v.Name] == "" { 91 | continue 92 | } 93 | kvp = append(kvp, types.KeyValuePair{ 94 | Name: aws.String(v.Env), 95 | Value: aws.String(p[v.Name]), 96 | }) 97 | } 98 | return kvp 99 | } 100 | 101 | func (p TaskParameter) ToECSTags(subdomain string, configParams Parameters) []types.Tag { 102 | tags := make([]types.Tag, 0, len(p)+3) 103 | tags = append(tags, 104 | types.Tag{ 105 | Key: aws.String(TagSubdomain), 106 | Value: aws.String(encodeTagValue(subdomain)), 107 | }, 108 | types.Tag{ 109 | Key: aws.String(TagManagedBy), 110 | Value: aws.String(TagValueMirage), 111 | }, 112 | ) 113 | for _, v := range configParams { 114 | v := v 115 | if p[v.Name] == "" { 116 | continue 117 | } 118 | tags = append(tags, types.Tag{ 119 | Key: aws.String(v.Name), 120 | Value: aws.String(p[v.Name]), 121 | }) 122 | } 123 | return tags 124 | } 125 | 126 | func (p TaskParameter) ToEnv(subdomain string, configParams Parameters, enc func(string) string) map[string]string { 127 | env := make(map[string]string, len(p)+1) 128 | env[EnvSubdomain] = enc(subdomain) 129 | env[EnvSubdomainRaw] = subdomain 130 | for _, v := range configParams { 131 | v := v 132 | if p[v.Name] == "" { 133 | continue 134 | } 135 | env[strings.ToUpper(v.Env)] = p[v.Name] 136 | } 137 | return env 138 | } 139 | 140 | const ( 141 | TagManagedBy = "ManagedBy" 142 | TagSubdomain = "Subdomain" 143 | TagValueMirage = "Mirage" 144 | 145 | EnvSubdomain = "SUBDOMAIN" 146 | EnvSubdomainRaw = "SUBDOMAINRAW" 147 | 148 | statusRunning = string(types.DesiredStatusRunning) 149 | statusStopped = string(types.DesiredStatusStopped) 150 | ) 151 | 152 | type TaskRunner interface { 153 | Launch(ctx context.Context, subdomain string, param TaskParameter, taskdefs ...string) error 154 | Logs(ctx context.Context, subdomain string, since time.Time, tail int) ([]string, error) 155 | Trace(ctx context.Context, id string) (string, error) 156 | Terminate(ctx context.Context, subdomain string) error 157 | TerminateBySubdomain(ctx context.Context, subdomain string) error 158 | List(ctx context.Context, status string) ([]*Information, error) 159 | SetProxyControlChannel(ch chan *proxyControl) 160 | GetAccessCount(ctx context.Context, subdomain string, duration time.Duration) (int64, error) 161 | PutAccessCounts(context.Context, map[string]accessCount) error 162 | } 163 | 164 | type ECS struct { 165 | cfg *Config 166 | svc *ecs.Client 167 | logsSvc *cwlogs.Client 168 | cwSvc *cw.Client 169 | proxyControlCh chan *proxyControl 170 | } 171 | 172 | func NewECSTaskRunner(cfg *Config) TaskRunner { 173 | e := &ECS{ 174 | cfg: cfg, 175 | svc: ecs.NewFromConfig(*cfg.awscfg), 176 | logsSvc: cwlogs.NewFromConfig(*cfg.awscfg), 177 | cwSvc: cw.NewFromConfig(*cfg.awscfg), 178 | } 179 | return e 180 | } 181 | 182 | func (e *ECS) SetProxyControlChannel(ch chan *proxyControl) { 183 | e.proxyControlCh = ch 184 | } 185 | 186 | func (e *ECS) launchTask(ctx context.Context, subdomain string, taskdef string, option TaskParameter) error { 187 | cfg := e.cfg 188 | 189 | slog.Info(f("launching task subdomain:%s taskdef:%s", subdomain, taskdef)) 190 | tdOut, err := e.svc.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ 191 | TaskDefinition: aws.String(taskdef), 192 | }) 193 | if err != nil { 194 | return fmt.Errorf("failed to describe task definition: %w", err) 195 | } 196 | 197 | // override envs for each container in taskdef 198 | ov := &types.TaskOverride{} 199 | env := option.ToECSKeyValuePairs(subdomain, cfg.Parameter, cfg.EncodeSubdomain) 200 | 201 | for _, c := range tdOut.TaskDefinition.ContainerDefinitions { 202 | name := *c.Name 203 | ov.ContainerOverrides = append( 204 | ov.ContainerOverrides, 205 | types.ContainerOverride{ 206 | Name: aws.String(name), 207 | Environment: env, 208 | }, 209 | ) 210 | } 211 | slog.Debug(f("Task Override: %v", ov)) 212 | 213 | tags := option.ToECSTags(subdomain, cfg.Parameter) 214 | runtaskInput := &ecs.RunTaskInput{ 215 | CapacityProviderStrategy: cfg.ECS.capacityProviderStrategy, 216 | Cluster: aws.String(cfg.ECS.Cluster), 217 | TaskDefinition: aws.String(taskdef), 218 | NetworkConfiguration: cfg.ECS.networkConfiguration, 219 | Overrides: ov, 220 | Count: aws.Int32(1), 221 | Tags: tags, 222 | EnableExecuteCommand: aws.ToBool(cfg.ECS.EnableExecuteCommand), 223 | } 224 | if lt := cfg.ECS.LaunchType; lt != nil { 225 | runtaskInput.LaunchType = types.LaunchType(*lt) 226 | } 227 | 228 | slog.Debug(f("RunTaskInput: %v", runtaskInput)) 229 | out, err := e.svc.RunTask(ctx, runtaskInput) 230 | if err != nil { 231 | return err 232 | } 233 | if len(out.Failures) > 0 { 234 | f := out.Failures[0] 235 | reason := "(unknown)" 236 | if f.Reason != nil { 237 | reason = *f.Reason 238 | } 239 | arn := "(unknown)" 240 | if f.Arn != nil { 241 | arn = *f.Arn 242 | } 243 | return fmt.Errorf( 244 | "run task failed. reason:%s arn:%s", reason, arn, 245 | ) 246 | } 247 | task := out.Tasks[0] 248 | slog.Info(f("launced task ARN: %s", *task.TaskArn)) 249 | return nil 250 | } 251 | 252 | func (e *ECS) Launch(ctx context.Context, subdomain string, option TaskParameter, taskdefs ...string) error { 253 | if infos, err := e.find(ctx, subdomain); err != nil { 254 | return fmt.Errorf("failed to get subdomain %s: %w", subdomain, err) 255 | } else if len(infos) > 0 { 256 | slog.Info(f("subdomain %s is already running %d tasks. Terminating...", subdomain, len(infos))) 257 | err := e.TerminateBySubdomain(ctx, subdomain) 258 | if err != nil { 259 | return err 260 | } 261 | } 262 | 263 | slog.Info(f("launching subdomain:%s taskdefs:%v", subdomain, taskdefs)) 264 | 265 | var eg errgroup.Group 266 | for _, taskdef := range taskdefs { 267 | taskdef := taskdef 268 | eg.Go(func() error { 269 | return e.launchTask(ctx, subdomain, taskdef, option) 270 | }) 271 | } 272 | return eg.Wait() 273 | } 274 | 275 | func (e *ECS) Trace(ctx context.Context, id string) (string, error) { 276 | tr, err := tracer.NewWithConfig(*e.cfg.awscfg) 277 | if err != nil { 278 | return "", err 279 | } 280 | tracerOpt := &tracer.RunOption{ 281 | Stdout: true, 282 | Duration: 5 * time.Minute, 283 | } 284 | buf := &strings.Builder{} 285 | tr.SetOutput(buf) 286 | if err := tr.Run(ctx, e.cfg.ECS.Cluster, id, tracerOpt); err != nil { 287 | return "", err 288 | } 289 | return buf.String(), nil 290 | } 291 | 292 | func (e *ECS) Logs(ctx context.Context, subdomain string, since time.Time, tail int) ([]string, error) { 293 | infos, err := e.find(ctx, subdomain) 294 | if err != nil { 295 | return nil, err 296 | } 297 | if len(infos) == 0 { 298 | return nil, fmt.Errorf("subdomain %s is not found", subdomain) 299 | } 300 | 301 | var logs []string 302 | var eg errgroup.Group 303 | var mu sync.Mutex 304 | for _, info := range infos { 305 | info := info 306 | eg.Go(func() error { 307 | l, err := e.logs(ctx, info, since, tail) 308 | mu.Lock() 309 | defer mu.Unlock() 310 | logs = append(logs, l...) 311 | return err 312 | }) 313 | } 314 | return logs, eg.Wait() 315 | } 316 | 317 | func (e *ECS) logs(ctx context.Context, info *Information, since time.Time, tail int) ([]string, error) { 318 | task := info.task 319 | taskdefOut, err := e.svc.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ 320 | TaskDefinition: task.TaskDefinitionArn, 321 | Include: []types.TaskDefinitionField{types.TaskDefinitionFieldTags}, 322 | }) 323 | if err != nil { 324 | return nil, fmt.Errorf("failed to describe task definition: %w", err) 325 | } 326 | 327 | streams := make(map[string][]string) 328 | for _, c := range taskdefOut.TaskDefinition.ContainerDefinitions { 329 | c := c 330 | logConf := c.LogConfiguration 331 | if logConf == nil { 332 | continue 333 | } 334 | if logConf.LogDriver != types.LogDriverAwslogs { 335 | slog.Warn(f("LogDriver %s is not supported", logConf.LogDriver)) 336 | continue 337 | } 338 | group := logConf.Options["awslogs-group"] 339 | streamPrefix := logConf.Options["awslogs-stream-prefix"] 340 | if group == "" || streamPrefix == "" { 341 | slog.Warn(f("invalid options. awslogs-group %s awslogs-stream-prefix %s", group, streamPrefix)) 342 | continue 343 | } 344 | // streamName: prefix/containerName/taskID 345 | streams[group] = append( 346 | streams[group], 347 | fmt.Sprintf("%s/%s/%s", streamPrefix, *c.Name, info.ShortID), 348 | ) 349 | } 350 | 351 | logs := []string{} 352 | for group, streamNames := range streams { 353 | group := group 354 | for _, stream := range streamNames { 355 | stream := stream 356 | slog.Debug(f("get log events from group:%s stream:%s start:%s", group, stream, since)) 357 | in := &cwlogs.GetLogEventsInput{ 358 | LogGroupName: aws.String(group), 359 | LogStreamName: aws.String(stream), 360 | } 361 | if !since.IsZero() { 362 | in.StartTime = aws.Int64(since.Unix() * 1000) 363 | } 364 | eventsOut, err := e.logsSvc.GetLogEvents(ctx, in) 365 | if err != nil { 366 | slog.Warn(f("failed to get log events from group %s stream %s: %s", group, stream, err)) 367 | continue 368 | } 369 | slog.Debug(f("%d log events", len(eventsOut.Events))) 370 | for _, ev := range eventsOut.Events { 371 | logs = append(logs, *ev.Message) 372 | } 373 | } 374 | } 375 | if tail > 0 && len(logs) >= tail { 376 | return logs[len(logs)-tail:], nil 377 | } 378 | return logs, nil 379 | } 380 | 381 | func (e *ECS) Terminate(ctx context.Context, taskArn string) error { 382 | slog.Info(f("stop task %s", taskArn)) 383 | _, err := e.svc.StopTask(ctx, &ecs.StopTaskInput{ 384 | Cluster: aws.String(e.cfg.ECS.Cluster), 385 | Task: aws.String(taskArn), 386 | Reason: aws.String("Terminate requested by Mirage"), 387 | }) 388 | return err 389 | } 390 | 391 | func (e *ECS) TerminateBySubdomain(ctx context.Context, subdomain string) error { 392 | infos, err := e.find(ctx, subdomain) 393 | if err != nil { 394 | return err 395 | } 396 | var eg errgroup.Group 397 | eg.Go(func() error { 398 | e.proxyControlCh <- &proxyControl{ 399 | Action: proxyRemove, 400 | Subdomain: subdomain, 401 | } 402 | return nil 403 | }) 404 | for _, info := range infos { 405 | info := info 406 | eg.Go(func() error { 407 | return e.Terminate(ctx, info.ID) 408 | }) 409 | } 410 | return eg.Wait() 411 | } 412 | 413 | func (e *ECS) find(ctx context.Context, subdomain string) ([]*Information, error) { 414 | var results []*Information 415 | 416 | infos, err := e.List(ctx, statusRunning) 417 | if err != nil { 418 | return nil, err 419 | } 420 | for _, info := range infos { 421 | if info.SubDomain == subdomain { 422 | results = append(results, info) 423 | } 424 | } 425 | return results, nil 426 | } 427 | 428 | func (e *ECS) List(ctx context.Context, desiredStatus string) ([]*Information, error) { 429 | slog.Debug(f("call ecs.List(%s)", desiredStatus)) 430 | infos := []*Information{} 431 | var nextToken *string 432 | cluster := aws.String(e.cfg.ECS.Cluster) 433 | include := []types.TaskField{types.TaskFieldTags} 434 | for { 435 | listOut, err := e.svc.ListTasks(ctx, &ecs.ListTasksInput{ 436 | Cluster: cluster, 437 | NextToken: nextToken, 438 | DesiredStatus: types.DesiredStatus(desiredStatus), 439 | }) 440 | if err != nil { 441 | return nil, fmt.Errorf("failed to list tasks: %w", err) 442 | } 443 | if len(listOut.TaskArns) == 0 { 444 | return []*Information{}, nil 445 | } 446 | 447 | tasksOut, err := e.svc.DescribeTasks(ctx, &ecs.DescribeTasksInput{ 448 | Cluster: cluster, 449 | Tasks: listOut.TaskArns, 450 | Include: include, 451 | }) 452 | if err != nil { 453 | return infos, fmt.Errorf("failed to describe tasks: %w", err) 454 | } 455 | 456 | for _, task := range tasksOut.Tasks { 457 | task := task 458 | if getTagsFromTask(&task, TagManagedBy) != TagValueMirage { 459 | // task is not managed by Mirage 460 | continue 461 | } 462 | info := &Information{ 463 | ID: *task.TaskArn, 464 | ShortID: shortenArn(*task.TaskArn), 465 | SubDomain: decodeTagValue(getTagsFromTask(&task, "Subdomain")), 466 | GitBranch: getEnvironmentFromTask(&task, "GIT_BRANCH"), 467 | TaskDef: shortenArn(*task.TaskDefinitionArn), 468 | IPAddress: getIPV4AddressFromTask(&task), 469 | LastStatus: *task.LastStatus, 470 | Env: getEnvironmentsFromTask(&task), 471 | Tags: task.Tags, 472 | task: &task, 473 | } 474 | if portMap, err := e.portMapInTask(ctx, &task); err != nil { 475 | slog.Warn(f("failed to get portMap in task %s %s", *task.TaskArn, err)) 476 | } else { 477 | info.PortMap = portMap 478 | } 479 | if task.StartedAt != nil { 480 | info.Created = (*task.StartedAt).In(time.Local) 481 | } 482 | infos = append(infos, info) 483 | } 484 | 485 | nextToken = listOut.NextToken 486 | if nextToken == nil { 487 | break 488 | } 489 | } 490 | 491 | sort.Slice(infos, func(i, j int) bool { 492 | return infos[i].SubDomain < infos[j].SubDomain 493 | }) 494 | 495 | return infos, nil 496 | } 497 | 498 | func shortenArn(arn string) string { 499 | p := strings.SplitN(arn, ":", 6) 500 | if len(p) != 6 { 501 | return "" 502 | } 503 | ps := strings.Split(p[5], "/") 504 | if len(ps) == 0 { 505 | return "" 506 | } 507 | return ps[len(ps)-1] 508 | } 509 | 510 | func getIPV4AddressFromTask(task *types.Task) string { 511 | if len(task.Attachments) == 0 { 512 | return "" 513 | } 514 | for _, d := range task.Attachments[0].Details { 515 | if *d.Name == "privateIPv4Address" { 516 | return *d.Value 517 | } 518 | } 519 | return "" 520 | } 521 | 522 | func getTagsFromTask(task *types.Task, name string) string { 523 | for _, t := range task.Tags { 524 | if *t.Key == name { 525 | return *t.Value 526 | } 527 | } 528 | return "" 529 | } 530 | 531 | func getEnvironmentFromTask(task *types.Task, name string) string { 532 | if len(task.Overrides.ContainerOverrides) == 0 { 533 | return "" 534 | } 535 | ov := task.Overrides.ContainerOverrides[0] 536 | for _, env := range ov.Environment { 537 | if *env.Name == name { 538 | return *env.Value 539 | } 540 | } 541 | return "" 542 | } 543 | 544 | func getEnvironmentsFromTask(task *types.Task) map[string]string { 545 | env := map[string]string{} 546 | if len(task.Overrides.ContainerOverrides) == 0 { 547 | return env 548 | } 549 | ov := task.Overrides.ContainerOverrides[0] 550 | for _, e := range ov.Environment { 551 | env[*e.Name] = *e.Value 552 | } 553 | return env 554 | } 555 | 556 | func encodeTagValue(s string) string { 557 | return base64.URLEncoding.EncodeToString([]byte(s)) 558 | } 559 | 560 | func decodeTagValue(s string) string { 561 | d, err := base64.URLEncoding.DecodeString(s) 562 | if err != nil { 563 | slog.Warn(f("failed to decode tag value %s %s", s, err)) 564 | return s 565 | } 566 | return string(d) 567 | } 568 | 569 | func (e *ECS) portMapInTask(ctx context.Context, task *types.Task) (map[string]int, error) { 570 | portMap := make(map[string]int) 571 | tdArn := *task.TaskDefinitionArn 572 | td, err := taskDefinitionCache.Get(tdArn) 573 | if err != nil && err == ttlcache.ErrNotFound { 574 | slog.Debug(f("cache miss for %s", tdArn)) 575 | out, err := e.svc.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ 576 | TaskDefinition: &tdArn, 577 | }) 578 | if err != nil { 579 | return nil, err 580 | } 581 | taskDefinitionCache.Set(tdArn, out.TaskDefinition) 582 | td = out.TaskDefinition 583 | } else { 584 | slog.Debug(f("cache hit for %s", tdArn)) 585 | } 586 | if _td, ok := td.(*types.TaskDefinition); ok { 587 | for _, c := range _td.ContainerDefinitions { 588 | for _, m := range c.PortMappings { 589 | if m.HostPort == nil { 590 | continue 591 | } 592 | portMap[*c.Name] = int(*m.HostPort) 593 | } 594 | } 595 | } else { 596 | slog.Warn(f("invalid type %s", td)) 597 | } 598 | return portMap, nil 599 | } 600 | 601 | func (e *ECS) GetAccessCount(ctx context.Context, subdomain string, duration time.Duration) (int64, error) { 602 | // truncate to minute 603 | // Period must be a multiple of 60 604 | // https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_GetMetricData.html 605 | duration = duration.Truncate(time.Minute) 606 | 607 | ctx, cancel := context.WithTimeout(ctx, APICallTimeout) 608 | defer cancel() 609 | res, err := e.cwSvc.GetMetricData(ctx, &cw.GetMetricDataInput{ 610 | StartTime: aws.Time(time.Now().Add(-duration)), 611 | EndTime: aws.Time(time.Now()), 612 | MetricDataQueries: []cwTypes.MetricDataQuery{ 613 | { 614 | Id: aws.String("request_count"), 615 | MetricStat: &cwTypes.MetricStat{ 616 | Metric: &cwTypes.Metric{ 617 | Dimensions: []cwTypes.Dimension{ 618 | { 619 | Name: aws.String(CloudWatchDimensionName), 620 | Value: aws.String(subdomain), 621 | }, 622 | }, 623 | MetricName: aws.String(CloudWatchMetricName), 624 | Namespace: aws.String(CloudWatchMetricNameSpace), 625 | }, 626 | Period: aws.Int32(int32(duration.Seconds())), 627 | Stat: aws.String("Sum"), 628 | }, 629 | }, 630 | }, 631 | }) 632 | if err != nil { 633 | return 0, err 634 | } 635 | var sum int64 636 | for _, v := range res.MetricDataResults { 637 | for _, vv := range v.Values { 638 | sum += int64(vv) 639 | } 640 | } 641 | return sum, nil 642 | } 643 | 644 | func (e *ECS) PutAccessCounts(ctx context.Context, all map[string]accessCount) error { 645 | metricData := make([]cwTypes.MetricDatum, 0, len(all)) 646 | for subdomain, counters := range all { 647 | for ts, count := range counters { 648 | slog.Debug(f("access for %s %s %d", subdomain, ts.Format(time.RFC3339), count)) 649 | metricData = append(metricData, cwTypes.MetricDatum{ 650 | MetricName: aws.String(CloudWatchMetricName), 651 | Timestamp: aws.Time(ts), 652 | Value: aws.Float64(float64(count)), 653 | Dimensions: []cwTypes.Dimension{ 654 | { 655 | Name: aws.String(CloudWatchDimensionName), 656 | Value: aws.String(subdomain), 657 | }, 658 | }, 659 | }) 660 | } 661 | } 662 | // CloudWatch API has a limit of 20 metric data per request 663 | var eg errgroup.Group 664 | for _, chunk := range lo.Chunk(metricData, 20) { 665 | chunk := chunk 666 | eg.Go(func() error { 667 | ctx, cancel := context.WithTimeout(ctx, APICallTimeout) 668 | defer cancel() 669 | pmInput := cw.PutMetricDataInput{ 670 | Namespace: aws.String(CloudWatchMetricNameSpace), 671 | MetricData: chunk, 672 | } 673 | _, err := e.cwSvc.PutMetricData(ctx, &pmInput) 674 | return err 675 | }) 676 | } 677 | return eg.Wait() 678 | } 679 | -------------------------------------------------------------------------------- /ecs_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/aws/aws-sdk-go-v2/service/ecs/types" 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/google/go-cmp/cmp/cmpopts" 12 | 13 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 14 | ) 15 | 16 | func TestToECSKeyValuePairsAndTags(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | taskParam mirageecs.TaskParameter 20 | configParams mirageecs.Parameters 21 | subdomain string 22 | expectedKVP []types.KeyValuePair 23 | expectedTags []types.Tag 24 | expectedEnv map[string]string 25 | compatV1 bool 26 | }{ 27 | { 28 | name: "Basic Test v1", 29 | taskParam: mirageecs.TaskParameter{ 30 | "Param1": "Value1", 31 | "Param2": "Value2", 32 | }, 33 | configParams: mirageecs.Parameters{ 34 | &mirageecs.Parameter{Name: "Param1", Env: "ENV1"}, 35 | &mirageecs.Parameter{Name: "Param2", Env: "ENV2"}, 36 | &mirageecs.Parameter{Name: "Param3", Env: "ENV3"}, 37 | }, 38 | subdomain: "testsubdomain", 39 | expectedKVP: []types.KeyValuePair{ 40 | {Name: aws.String("SUBDOMAIN"), Value: aws.String("dGVzdHN1YmRvbWFpbg==")}, 41 | {Name: aws.String("SUBDOMAINRAW"), Value: aws.String("testsubdomain")}, 42 | {Name: aws.String("ENV1"), Value: aws.String("Value1")}, 43 | {Name: aws.String("ENV2"), Value: aws.String("Value2")}, 44 | }, 45 | expectedTags: []types.Tag{ 46 | {Key: aws.String("Subdomain"), Value: aws.String("dGVzdHN1YmRvbWFpbg==")}, 47 | {Key: aws.String("ManagedBy"), Value: aws.String(mirageecs.TagValueMirage)}, 48 | {Key: aws.String("Param1"), Value: aws.String("Value1")}, 49 | {Key: aws.String("Param2"), Value: aws.String("Value2")}, 50 | }, 51 | expectedEnv: map[string]string{ 52 | "SUBDOMAIN": "dGVzdHN1YmRvbWFpbg==", 53 | "SUBDOMAINRAW": "testsubdomain", 54 | "ENV1": "Value1", 55 | "ENV2": "Value2", 56 | }, 57 | compatV1: true, 58 | }, 59 | { 60 | name: "Basic Test v2", 61 | taskParam: mirageecs.TaskParameter{ 62 | "Param1": "Value1", 63 | "Param2": "Value2", 64 | }, 65 | configParams: mirageecs.Parameters{ 66 | &mirageecs.Parameter{Name: "Param1", Env: "ENV1"}, 67 | &mirageecs.Parameter{Name: "Param2", Env: "ENV2"}, 68 | &mirageecs.Parameter{Name: "Param3", Env: "ENV3"}, 69 | }, 70 | subdomain: "testsubdomain", 71 | expectedKVP: []types.KeyValuePair{ 72 | {Name: aws.String("SUBDOMAIN"), Value: aws.String("testsubdomain")}, 73 | {Name: aws.String("SUBDOMAINRAW"), Value: aws.String("testsubdomain")}, 74 | {Name: aws.String("ENV1"), Value: aws.String("Value1")}, 75 | {Name: aws.String("ENV2"), Value: aws.String("Value2")}, 76 | }, 77 | expectedTags: []types.Tag{ 78 | {Key: aws.String("Subdomain"), Value: aws.String("dGVzdHN1YmRvbWFpbg==")}, 79 | {Key: aws.String("ManagedBy"), Value: aws.String(mirageecs.TagValueMirage)}, 80 | {Key: aws.String("Param1"), Value: aws.String("Value1")}, 81 | {Key: aws.String("Param2"), Value: aws.String("Value2")}, 82 | }, 83 | expectedEnv: map[string]string{ 84 | "SUBDOMAIN": "testsubdomain", 85 | "SUBDOMAINRAW": "testsubdomain", 86 | "ENV1": "Value1", 87 | "ENV2": "Value2", 88 | }, 89 | compatV1: false, 90 | }, 91 | } 92 | 93 | opt := cmpopts.IgnoreUnexported(types.KeyValuePair{}, types.Tag{}) 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | cfg, _ := mirageecs.NewConfig(context.Background(), &mirageecs.ConfigParams{ 97 | LocalMode: true, 98 | CompatV1: tt.compatV1, 99 | }) 100 | kvpResult := tt.taskParam.ToECSKeyValuePairs(tt.subdomain, tt.configParams, cfg.EncodeSubdomain) 101 | if diff := cmp.Diff(kvpResult, tt.expectedKVP, opt); diff != "" { 102 | t.Errorf("Mismatch in KeyValuePairs (-got +want):\n%s", diff) 103 | } 104 | tagsResult := tt.taskParam.ToECSTags(tt.subdomain, tt.configParams) 105 | if diff := cmp.Diff(tagsResult, tt.expectedTags, opt); diff != "" { 106 | t.Errorf("Mismatch in Tags (-got +want):\n%s", diff) 107 | } 108 | envResult := tt.taskParam.ToEnv(tt.subdomain, tt.configParams, cfg.EncodeSubdomain) 109 | if diff := cmp.Diff(envResult, tt.expectedEnv, opt); diff != "" { 110 | t.Errorf("Mismatch in Env (-got +want):\n%s", diff) 111 | } 112 | }) 113 | } 114 | } 115 | 116 | var purgeTests = []struct { 117 | name string 118 | param *mirageecs.APIPurgeRequest 119 | expected bool 120 | }{ 121 | { 122 | name: "young task", 123 | param: &mirageecs.APIPurgeRequest{ 124 | Duration: "600", // 10 minutes 125 | }, 126 | expected: false, 127 | }, 128 | { 129 | name: "old task", 130 | param: &mirageecs.APIPurgeRequest{ 131 | Duration: "300", // 5 minutes 132 | }, 133 | expected: true, 134 | }, 135 | { 136 | name: "excluded task", 137 | param: &mirageecs.APIPurgeRequest{ 138 | Duration: "300", 139 | Excludes: []string{"test"}, 140 | }, 141 | expected: false, 142 | }, 143 | { 144 | name: "excluded task not match", 145 | param: &mirageecs.APIPurgeRequest{ 146 | Duration: "300", 147 | Excludes: []string{"test2"}, 148 | }, 149 | expected: true, 150 | }, 151 | { 152 | name: "excluded tag", 153 | param: &mirageecs.APIPurgeRequest{ 154 | Duration: "300", 155 | ExcludeTags: []string{"DontPurge:true"}, 156 | }, 157 | expected: false, 158 | }, 159 | { 160 | name: "excluded regexp", 161 | param: &mirageecs.APIPurgeRequest{ 162 | Duration: "300", 163 | ExcludeRegexp: "te.t", 164 | }, 165 | expected: false, 166 | }, 167 | { 168 | name: "excluded regexp not match", 169 | param: &mirageecs.APIPurgeRequest{ 170 | Duration: "300", 171 | ExcludeRegexp: "xxx", 172 | }, 173 | expected: true, 174 | }, 175 | { 176 | name: "excluded tag not match", 177 | param: &mirageecs.APIPurgeRequest{ 178 | Duration: "300", 179 | ExcludeTags: []string{"xxx:true"}, 180 | }, 181 | expected: true, 182 | }, 183 | } 184 | 185 | func TestShouldBePurged(t *testing.T) { 186 | info := mirageecs.Information{ 187 | ID: "0123456789abcdef", 188 | ShortID: "testshortid", 189 | SubDomain: "test", 190 | GitBranch: "develop", 191 | TaskDef: "dummy", 192 | IPAddress: "127.0.0.1", 193 | Created: time.Now().Add(-7 * time.Minute), 194 | LastStatus: "RUNNING", 195 | PortMap: map[string]int{"http": 80}, 196 | Env: map[string]string{"ENV": "test"}, 197 | Tags: []types.Tag{ 198 | {Key: aws.String("Subdomain"), Value: aws.String("test")}, 199 | {Key: aws.String("DontPurge"), Value: aws.String("true")}, 200 | }, 201 | } 202 | for _, s := range purgeTests { 203 | t.Run(s.name, func(t *testing.T) { 204 | p, err := s.param.Validate() 205 | if err != nil { 206 | t.Errorf("Error in Validate: %v", err) 207 | } 208 | t.Logf("PurgeParams: %#v", p) 209 | if info.ShouldBePurged(p) != s.expected { 210 | t.Errorf("Mismatch in ShouldBePurged: %v", s) 211 | } 212 | }) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /export_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | var ( 4 | ValidateSubdomain = validateSubdomain 5 | NewHTTPTransport = newHTTPTransport 6 | ) 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/acidlemon/mirage-ecs/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.3 6 | 7 | require ( 8 | github.com/ReneKroon/ttlcache/v2 v2.11.0 9 | github.com/aws/aws-sdk-go-v2 v1.19.0 10 | github.com/aws/aws-sdk-go-v2/config v1.18.28 11 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.3 12 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.22.1 13 | github.com/aws/aws-sdk-go-v2/service/ecs v1.28.1 14 | github.com/aws/aws-sdk-go-v2/service/route53 v1.28.4 15 | github.com/aws/aws-sdk-go-v2/service/s3 v1.37.0 16 | github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd 17 | github.com/fujiwara/go-amzn-oidc v0.0.7 18 | github.com/fujiwara/tracer v1.0.2 19 | github.com/golang-jwt/jwt/v4 v4.5.2 20 | github.com/google/go-cmp v0.5.9 21 | github.com/kayac/go-config v0.7.0 22 | github.com/labstack/echo/v4 v4.13.3 23 | github.com/methane/rproxy v0.0.0-20130309122237-aafd1c66433b 24 | github.com/samber/lo v1.38.1 25 | github.com/winebarrel/cronplan v1.10.1 26 | golang.org/x/sync v0.12.0 27 | gopkg.in/yaml.v2 v2.4.0 28 | ) 29 | 30 | require ( 31 | github.com/BurntSushi/toml v1.3.2 // indirect 32 | github.com/alecthomas/participle/v2 v2.1.0 // indirect 33 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect 34 | github.com/aws/aws-sdk-go-v2/credentials v1.13.27 // indirect 35 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5 // indirect 36 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35 // indirect 37 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29 // indirect 38 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36 // indirect 39 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.27 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.30 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.4 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/sns v1.17.10 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.13 // indirect 46 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13 // indirect 47 | github.com/aws/aws-sdk-go-v2/service/sts v1.19.3 // indirect 48 | github.com/aws/smithy-go v1.13.5 // indirect 49 | github.com/jmespath/go-jmespath v0.4.0 // indirect 50 | github.com/labstack/gommon v0.4.2 // indirect 51 | github.com/mattn/go-colorable v0.1.13 // indirect 52 | github.com/mattn/go-isatty v0.0.20 // indirect 53 | github.com/shogo82148/go-retry v1.0.0 // indirect 54 | github.com/valyala/bytebufferpool v1.0.0 // indirect 55 | github.com/valyala/fasttemplate v1.2.2 // indirect 56 | golang.org/x/crypto v0.36.0 // indirect 57 | golang.org/x/exp v0.0.0-20230725012225-302865e7556b // indirect 58 | golang.org/x/net v0.38.0 // indirect 59 | golang.org/x/sys v0.31.0 // indirect 60 | golang.org/x/text v0.23.0 // indirect 61 | golang.org/x/time v0.8.0 // indirect 62 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 63 | ) 64 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/ReneKroon/ttlcache/v2 v2.11.0 h1:OvlcYFYi941SBN3v9dsDcC2N8vRxyHcCmJb3Vl4QMoM= 4 | github.com/ReneKroon/ttlcache/v2 v2.11.0/go.mod h1:mBxvsNY+BT8qLLd6CuAJubbKo6r0jh3nb5et22bbfGY= 5 | github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= 6 | github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= 7 | github.com/alecthomas/participle/v2 v2.1.0 h1:z7dElHRrOEEq45F2TG5cbQihMtNTv8vwldytDj7Wrz4= 8 | github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= 9 | github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= 10 | github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 11 | github.com/aws/aws-sdk-go-v2 v1.16.8/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw= 12 | github.com/aws/aws-sdk-go-v2 v1.19.0 h1:klAT+y3pGFBU/qVf1uzwttpBbiuozJYWzNLHioyDJ+k= 13 | github.com/aws/aws-sdk-go-v2 v1.19.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= 14 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= 15 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= 16 | github.com/aws/aws-sdk-go-v2/config v1.18.28 h1:TINEaKyh1Td64tqFvn09iYpKiWjmHYrG1fa91q2gnqw= 17 | github.com/aws/aws-sdk-go-v2/config v1.18.28/go.mod h1:nIL+4/8JdAuNHEjn/gPEXqtnS02Q3NXB/9Z7o5xE4+A= 18 | github.com/aws/aws-sdk-go-v2/credentials v1.13.27 h1:dz0yr/yR1jweAnsCx+BmjerUILVPQ6FS5AwF/OyG1kA= 19 | github.com/aws/aws-sdk-go-v2/credentials v1.13.27/go.mod h1:syOqAek45ZXZp29HlnRS/BNgMIW6uiRmeuQsz4Qh2UE= 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5 h1:kP3Me6Fy3vdi+9uHd7YLr6ewPxRL+PU6y15urfTaamU= 21 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5/go.mod h1:Gj7tm95r+QsDoN2Fhuz/3npQvcZbkEf5mL70n3Xfluc= 22 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.15/go.mod h1:pWrr2OoHlT7M/Pd2y4HV3gJyPb3qj5qMmnPkKSNPYK4= 23 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35 h1:hMUCiE3Zi5AHrRNGf5j985u0WyqI6r2NULhUfo0N/No= 24 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35/go.mod h1:ipR5PvpSPqIqL5Mi82BxLnfMkHVbmco8kUwO2xrCi0M= 25 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.9/go.mod h1:08tUpeSGN33QKSO7fwxXczNfiwCpbj+GxK6XKwqWVv0= 26 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29 h1:yOpYx+FTBdpk/g+sBU6Cb1H0U/TLEcYYp66mYqsPpcc= 27 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29/go.mod h1:M/eUABlDbw2uVrdAn+UsI6M727qp2fxkp8K0ejcBDUY= 28 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36 h1:8r5m1BoAWkn0TDC34lUculryf7nUF25EgIMdjvGCkgo= 29 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36/go.mod h1:Rmw2M1hMVTwiUhjwMoIBFWFJMhvJbct06sSidxInkhY= 30 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.27 h1:cZG7psLfqpkB6H+fIrgUDWmlzM474St1LP0jcz272yI= 31 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.27/go.mod h1:ZdjYvJpDlefgh8/hWelJhqgqJeodxu4SmbVsSdBlL7E= 32 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.3 h1:sAqtjjMc1DdA0JnYKKuqJVt/eHLTuN7bDf2T4UQ9sDs= 33 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.26.3/go.mod h1:r6kXYdL8M2/BnZatWvQ8yC/3UQvPrXTQnJtZ0xEbKRM= 34 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.22.1 h1:qm8LnOQM9yHwfGI7kY2W3gpd3hKttGuKkWplI7fHGH4= 35 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.22.1/go.mod h1:4tbPbziIVYtGAoIqr939uQmg6G/RAbZtU9j4384r1LI= 36 | github.com/aws/aws-sdk-go-v2/service/ecs v1.28.1 h1:PxWgrtfQvct60NjxSrFsSWG/Yg1HATRKP4IeUPiLlrE= 37 | github.com/aws/aws-sdk-go-v2/service/ecs v1.28.1/go.mod h1:eZBCsRjzc+ZX8x3h0beHOu+uxRWRwnEHzzvDgKy9v0E= 38 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= 39 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= 40 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.30 h1:Bje8Xkh2OWpjBdNfXLrnn8eZg569dUQmhgtydxAYyP0= 41 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.30/go.mod h1:qQtIBl5OVMfmeQkz8HaVyh5DzFmmFXyvK27UgIgOr4c= 42 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29 h1:IiDolu/eLmuB18DRZibj77n1hHQT7z12jnGO7Ze3pLc= 43 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29/go.mod h1:fDbkK4o7fpPXWn8YAPmTieAMuB9mk/VgvW64uaUqxd4= 44 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.4 h1:hx4WksB0NRQ9utR+2c3gEGzl6uKj3eM6PMQ6tN3lgXs= 45 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.4/go.mod h1:JniVpqvw90sVjNqanGLufrVapWySL28fhBlYgl96Q/w= 46 | github.com/aws/aws-sdk-go-v2/service/route53 v1.28.4 h1:p4mTxJfCAyiTT4Wp6p/mOPa6j5MqCSRGot8qZwFs+Z0= 47 | github.com/aws/aws-sdk-go-v2/service/route53 v1.28.4/go.mod h1:VBLWpaHvhQNeu7N9rMEf00SWeOONb/HvaDUxe/7b44k= 48 | github.com/aws/aws-sdk-go-v2/service/s3 v1.37.0 h1:PalLOEGZ/4XfQxpGZFTLaoJSmPoybnqJYotaIZEf/Rg= 49 | github.com/aws/aws-sdk-go-v2/service/s3 v1.37.0/go.mod h1:PwyKKVL0cNkC37QwLcrhyeCrAk+5bY8O2ou7USyAS2A= 50 | github.com/aws/aws-sdk-go-v2/service/sns v1.17.10 h1:ZZuqucIwjbUEJqxxR++VDZX9BcMbX5ZcQaKoWul/ELk= 51 | github.com/aws/aws-sdk-go-v2/service/sns v1.17.10/go.mod h1:uITsRNVMeCB3MkWpXxXw0eDz8pW4TYLzj+eyQtbhSxM= 52 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.13 h1:sWDv7cMITPcZ21QdreULwxOOAmE05JjEsT6fCDtDA9k= 53 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.13/go.mod h1:DfX0sWuT46KpcqbMhJ9QWtxAIP1VozkDWf8VAkByjYY= 54 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13 h1:BFubHS/xN5bjl818QaroN6mQdjneYQ+AOx44KNXlyH4= 55 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13/go.mod h1:BzqsVVFduubEmzrVtUFQQIQdFqvUItF8XUq2EnS8Wog= 56 | github.com/aws/aws-sdk-go-v2/service/sts v1.19.3 h1:e5mnydVdCVWxP+5rPAGi2PYxC7u2OZgH1ypC114H04U= 57 | github.com/aws/aws-sdk-go-v2/service/sts v1.19.3/go.mod h1:yVGZA1CPkmUhBdA039jXNJJG7/6t+G+EBWmFq23xqnY= 58 | github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= 59 | github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= 60 | github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= 61 | github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd h1:C0dfBzAdNMqxokqWUysk2KTJSMmqvh9cNW1opdy5+0Q= 62 | github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd/go.mod h1:CeKhh8xSs3WZAc50xABMxu+FlfAAd5PNumo7NfOv7EE= 63 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 64 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 65 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 66 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 67 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 68 | github.com/fujiwara/go-amzn-oidc v0.0.7 h1:PlvfdGu0QKIy3MHmAsSeHOGpMcPC7k8VTmXtg3y8oBQ= 69 | github.com/fujiwara/go-amzn-oidc v0.0.7/go.mod h1:KwCMzB/xJ+0ehAQ0+ResFXgl5h0xHr1noDrQadqhKVE= 70 | github.com/fujiwara/tracer v1.0.2 h1:ztstnson+QwOpO69Jir4nkUKlYgse3vJ28FO2eOUPk0= 71 | github.com/fujiwara/tracer v1.0.2/go.mod h1:r2QzBEBNsW9OhmoVdmTANG+GEmxWNZk7317/mnW2yIw= 72 | github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 73 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 74 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 75 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 76 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 77 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 78 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 79 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 80 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 81 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 82 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 83 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 84 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 85 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 86 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 87 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 88 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 89 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 90 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 91 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 92 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 93 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 94 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 95 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 96 | github.com/kayac/go-config v0.7.0 h1:BeONaFFq/ILFiEzkCMpKarsjcc3YBgJ7QKg39hXU+nk= 97 | github.com/kayac/go-config v0.7.0/go.mod h1:Nfkw4LZOh/7HGepftBvD2lKEpPyl1Vp89yA7gDJS5r0= 98 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 99 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 100 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 101 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 102 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 103 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 104 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 105 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 106 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 107 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 108 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 109 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 110 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 111 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 112 | github.com/methane/rproxy v0.0.0-20130309122237-aafd1c66433b h1:b0UQLKwxhmjHjmU5G7MjZb/znNzrVbNXo7cqBFbheMM= 113 | github.com/methane/rproxy v0.0.0-20130309122237-aafd1c66433b/go.mod h1:dFFVhfY408BCzenWxVoBko8GGhFvaWJWMlbLn/fICO8= 114 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 115 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 116 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 117 | github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 118 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 119 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 120 | github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= 121 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 122 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 123 | github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= 124 | github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 125 | github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs= 126 | github.com/shogo82148/go-retry v1.0.0 h1:c487Qe+QYUffpUpPxrUN5fGJq6WVHSzGS4N3MNuR2OU= 127 | github.com/shogo82148/go-retry v1.0.0/go.mod h1:5jiw5yPWW6K+pMyimtNoaQSDD08RMEsJbhDwFrui5rc= 128 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 129 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 130 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 131 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 132 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 133 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 134 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 135 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 136 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 137 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 138 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 139 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 140 | github.com/winebarrel/cronplan v1.10.1 h1:sTnmKWpGjXr3tDpgSSTCohltaimpBNXW/LgFf0SEMwo= 141 | github.com/winebarrel/cronplan v1.10.1/go.mod h1:FXpmoZVzj9eZoyHe1lpUezcFL3Tk6p5OBSovWeHq4qY= 142 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 143 | go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= 144 | go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= 145 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 146 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 147 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 148 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 149 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 150 | golang.org/x/exp v0.0.0-20230725012225-302865e7556b h1:tK7yjGqVRzYdXsBcfD2MLhFAhHfDgGLm2rY1ub7FA9k= 151 | golang.org/x/exp v0.0.0-20230725012225-302865e7556b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 152 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 153 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= 154 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 155 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 156 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 157 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 158 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 159 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 160 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 161 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 162 | golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 163 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 164 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 165 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 166 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 167 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 168 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 169 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 170 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 171 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 172 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 173 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 174 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 175 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 176 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 177 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 178 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 179 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 180 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 181 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 183 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 184 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 185 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 186 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 187 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 188 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 189 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 190 | golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 191 | golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 192 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 193 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 194 | golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 195 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 196 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 197 | golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 198 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 199 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 200 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 201 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 202 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 203 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 204 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 205 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 206 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 207 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 208 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 209 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 210 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 211 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 212 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 213 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 214 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 215 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 216 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 217 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 218 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 219 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 220 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 221 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 222 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 223 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 224 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 225 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 226 | -------------------------------------------------------------------------------- /html/launcher.html: -------------------------------------------------------------------------------- 1 |
Error occurred while retreiving information. Detail: {{ .error }}
3 | {{ else }} 4 | 5 |