├── data └── .gitkeep ├── .dockerignore ├── terraform ├── .terraform-version ├── .gitignore ├── logs.tf ├── ecs.tf ├── ecspresso.jsonnet ├── config.yaml ├── acm.tf ├── s3.tf ├── config.tf ├── sg.tf ├── ecs-service-def.jsonnet ├── route53.tf ├── ecs-task-def.jsonnet ├── README.md ├── vpc.tf ├── iam.tf ├── .terraform.lock.hcl └── alb.tf ├── .gitignore ├── docs ├── mirage-ecs-list.png └── mirage-ecs-launcher.png ├── export_test.go ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── Makefile ├── docker ├── Dockerfile └── example-config.yml ├── .goreleaser.yml ├── access_counter_test.go ├── purge.go ├── ecs-task-def.json ├── access_counter.go ├── purge_test.go ├── config_sample.yml ├── cmd └── mirage-ecs │ └── main.go ├── html ├── layout.html ├── list.html └── launcher.html ├── log.go ├── reverseproxy_test.go ├── go.mod ├── config_test.go ├── transport_test.go ├── types.go ├── webapi_test.go ├── route53.go ├── local.go ├── mirage.go ├── ecs_test.go ├── e2e_test.go ├── auth.go ├── config_auth_test.go ├── auth_test.go ├── reverseproxy.go ├── webapi.go ├── config.go ├── ecs.go └── go.sum /data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | pkg 2 | terraform/ 3 | -------------------------------------------------------------------------------- /terraform/.terraform-version: -------------------------------------------------------------------------------- 1 | 1.4.6 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | mirage 3 | ./mirage-ecs 4 | *~ 5 | .envrc 6 | pkg 7 | -------------------------------------------------------------------------------- /terraform/.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .terraform/ 3 | terraform.tfstate* 4 | terraform.tfvars 5 | -------------------------------------------------------------------------------- /docs/mirage-ecs-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/HEAD/docs/mirage-ecs-list.png -------------------------------------------------------------------------------- /docs/mirage-ecs-launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidlemon/mirage-ecs/HEAD/docs/mirage-ecs-launcher.png -------------------------------------------------------------------------------- /terraform/logs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "mirage-ecs" { 2 | name = "/aws/ecs/${var.project}" 3 | } 4 | -------------------------------------------------------------------------------- /export_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | var ( 4 | ValidateSubdomain = validateSubdomain 5 | NewHTTPTransport = newHTTPTransport 6 | ) 7 | -------------------------------------------------------------------------------- /terraform/ecs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_cluster" "mirage-ecs" { 2 | name = var.project 3 | tags = { 4 | Name = var.project 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /terraform/ecspresso.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | region: 'ap-northeast-1', 3 | cluster: 'mirage-ecs', 4 | service: 'mirage-ecs', 5 | service_definition: 'ecs-service-def.jsonnet', 6 | task_definition: 'ecs-task-def.jsonnet', 7 | timeout: '10m0s', 8 | plugins: [ 9 | { 10 | name: "tfstate", 11 | config: { 12 | url: "terraform.tfstate" 13 | }, 14 | }, 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /terraform/config.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: additional 3 | env: ADDITIONAL 4 | default: "foo" 5 | description: "Additional parameter" 6 | options: 7 | - label: "Foo" 8 | value: "foo" 9 | - label: "Bar" 10 | value: "bar" 11 | htmldir: "{{ must_env `HTMLDIR` }}" 12 | #auth: 13 | # amzn_oidc: 14 | # claim: email 15 | # matchers: 16 | # - suffix: "@gmail.com" 17 | -------------------------------------------------------------------------------- /terraform/acm.tf: -------------------------------------------------------------------------------- 1 | resource "aws_acm_certificate" "mirage-ecs" { 2 | domain_name = var.domain 3 | validation_method = "DNS" 4 | subject_alternative_names = [ 5 | "*.${var.domain}" 6 | ] 7 | tags = { 8 | Name = var.project 9 | } 10 | } 11 | 12 | resource "aws_acm_certificate_validation" "mirage-ecs" { 13 | certificate_arn = aws_acm_certificate.mirage-ecs.arn 14 | validation_record_fqdns = [for v in aws_route53_record.validation : v.fqdn] 15 | } 16 | -------------------------------------------------------------------------------- /terraform/s3.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "mirage-ecs" { 2 | bucket = format("mirage-%s", replace(var.domain, ".", "-")) 3 | } 4 | 5 | resource "aws_s3_object" "config" { 6 | bucket = aws_s3_bucket.mirage-ecs.id 7 | key = "config.yaml" 8 | source = "config.yaml" 9 | } 10 | 11 | resource "aws_s3_object" "html" { 12 | for_each = toset(["launcher.html", "layout.html", "list.html"]) 13 | bucket = aws_s3_bucket.mirage-ecs.id 14 | key = "html/${each.value}" 15 | source = format("../html/%s", each.value) 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go: 8 | - "1.24" 9 | - "1.25" 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: ${{ matrix.go }} 17 | id: go 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v4 21 | 22 | - name: Build & Test 23 | run: | 24 | make test 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /purge.go: -------------------------------------------------------------------------------- 1 | package mirageecs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/winebarrel/cronplan" 7 | ) 8 | 9 | type Purge struct { 10 | Schedule string `json:"schedule" yaml:"schedule"` 11 | Request *APIPurgeRequest `json:"request" yaml:"request"` 12 | 13 | PurgeParams *PurgeParams `json:"-" yaml:"-"` 14 | Cron *cronplan.Expression `json:"-" yaml:"-"` 15 | } 16 | 17 | func (p *Purge) Validate() error { 18 | cron, err := cronplan.Parse(p.Schedule) 19 | if err != nil { 20 | return fmt.Errorf("invalid schedule expression %s: %w", p.Schedule, err) 21 | } 22 | p.Cron = cron 23 | 24 | if p.Request == nil { 25 | return fmt.Errorf("purge request is required") 26 | } 27 | purgeParams, err := p.Request.Validate() 28 | if err != nil { 29 | return fmt.Errorf("invalid purge request: %w", err) 30 | } 31 | p.PurgeParams = purgeParams 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /terraform/config.tf: -------------------------------------------------------------------------------- 1 | variable "project" { 2 | type = string 3 | default = "mirage-ecs" 4 | } 5 | 6 | provider "aws" { 7 | region = "ap-northeast-1" 8 | default_tags { 9 | tags = { 10 | "env" = "${var.project}" 11 | } 12 | } 13 | } 14 | 15 | terraform { 16 | required_version = "= 1.4.6" 17 | 18 | required_providers { 19 | aws = { 20 | source = "hashicorp/aws" 21 | version = "= 4.65.0" 22 | } 23 | } 24 | } 25 | 26 | data "aws_caller_identity" "current" { 27 | } 28 | 29 | variable "domain" { 30 | type = string 31 | } 32 | 33 | variable "oauth_client_id" { 34 | type = string 35 | default = "" 36 | } 37 | 38 | variable "oauth_client_secret" { 39 | type = string 40 | default = "" 41 | } 42 | 43 | provider "http" {} 44 | 45 | data "http" "oidc_configuration" { 46 | url = "https://accounts.google.com/.well-known/openid-configuration" 47 | } 48 | -------------------------------------------------------------------------------- /terraform/sg.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "default" { 2 | name = "${var.project}-default" 3 | vpc_id = aws_vpc.main.id 4 | ingress { 5 | self = true 6 | from_port = 0 7 | to_port = 0 8 | protocol = "-1" 9 | } 10 | egress { 11 | from_port = 0 12 | to_port = 0 13 | protocol = "-1" 14 | cidr_blocks = ["0.0.0.0/0"] 15 | } 16 | } 17 | 18 | resource "aws_security_group" "alb" { 19 | name = "${var.project}-alb" 20 | vpc_id = aws_vpc.main.id 21 | ingress { 22 | from_port = 80 23 | to_port = 80 24 | protocol = "tcp" 25 | cidr_blocks = ["0.0.0.0/0"] 26 | } 27 | ingress { 28 | from_port = 443 29 | to_port = 443 30 | protocol = "tcp" 31 | cidr_blocks = ["0.0.0.0/0"] 32 | } 33 | egress { 34 | from_port = 0 35 | to_port = 0 36 | protocol = "-1" 37 | cidr_blocks = ["0.0.0.0/0"] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /terraform/ecs-service-def.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | deploymentConfiguration: { 3 | deploymentCircuitBreaker: { 4 | enable: false, 5 | rollback: false, 6 | }, 7 | maximumPercent: 200, 8 | minimumHealthyPercent: 100, 9 | }, 10 | deploymentController: { 11 | type: 'ECS', 12 | }, 13 | desiredCount: 1, 14 | enableECSManagedTags: false, 15 | enableExecuteCommand: true, 16 | healthCheckGracePeriodSeconds: 0, 17 | launchType: 'FARGATE', 18 | loadBalancers: [ 19 | { 20 | containerName: 'mirage-ecs', 21 | containerPort: 80, 22 | targetGroupArn: '{{ tfstate `aws_lb_target_group.mirage-ecs-http.arn` }}', 23 | }, 24 | ], 25 | networkConfiguration: { 26 | awsvpcConfiguration: { 27 | assignPublicIp: 'ENABLED', 28 | securityGroups: [ 29 | '{{ tfstate `aws_security_group.default.id` }}', 30 | ], 31 | subnets: [ 32 | '{{ tfstate `aws_subnet.public-a.id` }}', 33 | '{{ tfstate `aws_subnet.public-c.id` }}', 34 | '{{ tfstate `aws_subnet.public-d.id` }}', 35 | ], 36 | }, 37 | }, 38 | platformFamily: 'Linux', 39 | platformVersion: 'LATEST', 40 | propagateTags: 'SERVICE', 41 | runningCount: 0, 42 | schedulingStrategy: 'REPLICA', 43 | tags: [ 44 | { 45 | key: 'env', 46 | value: 'mirage-ecs', 47 | }, 48 | ], 49 | } 50 | -------------------------------------------------------------------------------- /terraform/route53.tf: -------------------------------------------------------------------------------- 1 | resource "aws_route53_zone" "mirage-ecs" { 2 | name = var.domain 3 | } 4 | 5 | resource "aws_route53_record" "mirage-ecs" { 6 | zone_id = aws_route53_zone.mirage-ecs.zone_id 7 | name = "mirage.${var.domain}" 8 | type = "A" 9 | alias { 10 | name = aws_lb.mirage-ecs.dns_name 11 | zone_id = aws_lb.mirage-ecs.zone_id 12 | evaluate_target_health = true 13 | } 14 | } 15 | 16 | resource "aws_route53_record" "mirage-tasks" { 17 | zone_id = aws_route53_zone.mirage-ecs.zone_id 18 | name = "*.${var.domain}" 19 | type = "A" 20 | alias { 21 | name = aws_lb.mirage-ecs.dns_name 22 | zone_id = aws_lb.mirage-ecs.zone_id 23 | evaluate_target_health = true 24 | } 25 | } 26 | 27 | resource "aws_route53_record" "validation" { 28 | zone_id = aws_route53_zone.mirage-ecs.zone_id 29 | for_each = { 30 | for dvo in aws_acm_certificate.mirage-ecs.domain_validation_options : dvo.domain_name => { 31 | name = dvo.resource_record_name 32 | record = dvo.resource_record_value 33 | type = dvo.resource_record_type 34 | } 35 | } 36 | name = each.value.name 37 | records = [each.value.record] 38 | type = each.value.type 39 | allow_overwrite = true 40 | ttl = 60 41 | } 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /purge_test.go: -------------------------------------------------------------------------------- 1 | package mirageecs_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | mirageecs "github.com/acidlemon/mirage-ecs/v2" 8 | "github.com/kayac/go-config" 9 | ) 10 | 11 | func TestPurgeConfig(t *testing.T) { 12 | cfg := mirageecs.Config{} 13 | err := config.LoadWithEnvBytes(&cfg, []byte(` 14 | purge: 15 | schedule: "*/3 * * * ? *" # every 3 minutes 16 | request: 17 | duration: "300" # 5 minutes 18 | excludes: 19 | - "test" 20 | - "test2" 21 | exclude_tags: 22 | - "DontPurge:true" 23 | exclude_regexp: "te.t" 24 | `)) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | if err := cfg.Purge.Validate(); err != nil { 29 | t.Fatal(err) 30 | } 31 | now := time.Date(2024, 11, 7, 11, 22, 33, 0, time.UTC) 32 | next := cfg.Purge.Cron.Next(now) 33 | if next != time.Date(2024, 11, 7, 11, 24, 0, 0, time.UTC) { 34 | t.Errorf("unexpected next time: %s", next) 35 | } 36 | if cfg.Purge.PurgeParams.Duration != time.Second * 300 { 37 | t.Errorf("unexpected duration: %d", cfg.Purge.PurgeParams.Duration) 38 | } 39 | if len(cfg.Purge.PurgeParams.Excludes) != 2 { 40 | t.Errorf("unexpected excludes: %v", cfg.Purge.PurgeParams.Excludes) 41 | } 42 | if len(cfg.Purge.PurgeParams.ExcludeTags) != 1 { 43 | t.Errorf("unexpected exclude_tags: %v", cfg.Purge.PurgeParams.ExcludeTags) 44 | } 45 | if !cfg.Purge.PurgeParams.ExcludeRegexp.MatchString("test") { 46 | t.Errorf("unexpected exclude_regexp: %v", cfg.Purge.PurgeParams.ExcludeRegexp) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /terraform/ecs-task-def.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | cpu: '256', 3 | memory: '512', 4 | containerDefinitions: [ 5 | { 6 | name: 'mirage-ecs', 7 | image: 'ghcr.io/acidlemon/mirage-ecs:{{ env `VERSION` `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: '{{ tfstate `aws_route53_zone.mirage-ecs.name` }}', 20 | }, 21 | { 22 | name: 'MIRAGE_LOG_LEVEL', 23 | value: '{{ env `LOG_LEVEL` `info` }}', 24 | }, 25 | { 26 | name: 'MIRAGE_CONF', 27 | value: 's3://{{ tfstate `aws_s3_bucket.mirage-ecs.bucket` }}/config.yaml' 28 | }, 29 | { 30 | name: 'HTMLDIR', 31 | value: 's3://{{ tfstate `aws_s3_bucket.mirage-ecs.bucket` }}/html' 32 | }, 33 | ], 34 | logConfiguration: { 35 | logDriver: 'awslogs', 36 | options: { 37 | 'awslogs-group': '{{ tfstate `aws_cloudwatch_log_group.mirage-ecs.name` }}', 38 | 'awslogs-region': '{{ must_env `AWS_REGION` }}', 39 | 'awslogs-stream-prefix': 'mirage-ecs', 40 | }, 41 | }, 42 | }, 43 | ], 44 | family: 'mirage-ecs', 45 | taskRoleArn: '{{ tfstate `aws_iam_role.task.arn` }}', 46 | executionRoleArn: '{{ tfstate `data.aws_iam_role.ecs-task-execiton.arn` }}', 47 | networkMode: 'awsvpc', 48 | requiresCompatibilities: [ 49 | "EC2", 50 | "FARGATE", 51 | ], 52 | } 53 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | ## An example of mirage-ecs deployment using terraform 2 | 3 | This example shows how to deploy mirage-ecs using terraform. 4 | 5 | ### Prerequisites 6 | 7 | - [Terraform](https://www.terraform.io/) >= v1.0.0 8 | - [ecspresso](https://github.com/kayac/ecspresso) >= v2.0.0 9 | 10 | #### Environment variables 11 | 12 | - `AWS_REGION` for AWS region. (e.g. `ap-northeast-1`) 13 | - `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or `AWS_PROFILE` for AWS credentials. 14 | - `AWS_SDK_LOAD_CONFIG=true` may be required if you use `AWS_PROFILE` and `~/.aws/config`. 15 | 16 | ### Usage 17 | 18 | ```console 19 | $ terraform init 20 | $ terraform apply -var domain=dev.your.example.com 21 | $ ecspresso deploy 22 | ``` 23 | 24 | While applying terraform, `dev.your.example.com` will be registered to Route53. 25 | You should delegate `dev.your.example.com` to the name servers from `your.example.com`. 26 | 27 | After deploying, you can access to `https://mirage.dev.your.example.com` and see the mirage-ecs. 28 | 29 | #### Customization 30 | 31 | You can customize the deployment by editing `terraform.tfvars` and `ecspresso.yml`. 32 | 33 | `oauth_client_id` and `oauth_client_secret` are used for authentication by ALB with Google OAuth. 34 | If you want to enable authentication, you should set them. 35 | Set the Google OAuth callback URL to `https://mirage.{var.domain}/oauth2/idpresponse`. 36 | 37 | `ecspresso.yml` is used for ECS deployment. 38 | See [ecspresso](https://github.com/kayac/ecspresso) for details. 39 | 40 | ### Cleanup 41 | 42 | ```console 43 | $ ecspresso delete --terminate 44 | $ terraform destroy -var domain=dev.your.example.com 45 | ``` 46 | -------------------------------------------------------------------------------- /terraform/vpc.tf: -------------------------------------------------------------------------------- 1 | resource "aws_vpc" "main" { 2 | cidr_block = "10.0.0.0/16" 3 | tags = { 4 | Name = var.project 5 | } 6 | } 7 | 8 | resource "aws_subnet" "public-a" { 9 | vpc_id = aws_vpc.main.id 10 | cidr_block = "10.0.1.0/24" 11 | availability_zone = "ap-northeast-1a" 12 | tags = { 13 | Name = "${var.project}-public-a" 14 | } 15 | } 16 | 17 | resource "aws_subnet" "public-c" { 18 | vpc_id = aws_vpc.main.id 19 | cidr_block = "10.0.2.0/24" 20 | availability_zone = "ap-northeast-1c" 21 | tags = { 22 | Name = "${var.project}-public-c" 23 | } 24 | } 25 | 26 | resource "aws_subnet" "public-d" { 27 | vpc_id = aws_vpc.main.id 28 | cidr_block = "10.0.3.0/24" 29 | availability_zone = "ap-northeast-1d" 30 | tags = { 31 | Name = "${var.project}-public-d" 32 | } 33 | } 34 | 35 | resource "aws_internet_gateway" "main" { 36 | vpc_id = aws_vpc.main.id 37 | tags = { 38 | Name = var.project 39 | } 40 | } 41 | 42 | resource "aws_route_table" "public" { 43 | vpc_id = aws_vpc.main.id 44 | route { 45 | cidr_block = "0.0.0.0/0" 46 | gateway_id = aws_internet_gateway.main.id 47 | } 48 | tags = { 49 | Name = "${var.project}-public" 50 | } 51 | } 52 | 53 | resource "aws_route_table_association" "public-a" { 54 | subnet_id = aws_subnet.public-a.id 55 | route_table_id = aws_route_table.public.id 56 | } 57 | 58 | resource "aws_route_table_association" "public-c" { 59 | subnet_id = aws_subnet.public-c.id 60 | route_table_id = aws_route_table.public.id 61 | } 62 | 63 | resource "aws_route_table_association" "public-d" { 64 | subnet_id = aws_subnet.public-d.id 65 | route_table_id = aws_route_table.public.id 66 | } 67 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /html/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |Error occurred while retreiving information. Detail: {{ .error }}
3 | {{ else }} 4 | 5 |